Repository: GoogleChrome/lighthouse (js)

Issue:
Default GZIP compression ratio is pessimistic for CSS
<!-- Before creating an issue please make sure you are using the latest version and have checked for duplicate issues. -->

<!-- Before creating an Accessibility issue please test that it is reproducible upstream with axe (https://www.deque.com/axe/) first and file the issue there if necessary. -->

#### Provide the steps to reproduce
1. Run LH with Google Pagespeed on https://mopinion.com/test-pagespeed/
Note: the page is not otherwise optimized for speed.

#### What is the current behavior?
Our 3rd party javascript tool fetches it's styles via gzipped json and then appends it in a style tag to the DOM. 
Pagespeed is reporting a size of 80kb and a potential savings of +- 70kb under 'Defer unused CSS' when the total configuration fetched by our tool is only around 20kb gzipped. 
This regarding the following entry:
<img width="929" alt="schermafbeelding 2019-02-07 om 15 56 41" src="https://user-images.githubusercontent.com/5699234/52420017-8813e180-2af1-11e9-8ebe-d00af199b568.png">

#### What is the expected behavior?
Calculated scores and savings should be based on actually fetched resources, size in dom does not perse reflect actual data fetched.

#### Environment Information
* Affected Channels: Pagespeed
* Lighthouse version:-
* Node.js version:-
* Operating System:-

**Related issues**
https://github.com/GoogleChrome/lighthouse/issues/7141

Pull request description:
core(byte-efficiency): use more optimistic GZIP ratios
**Summary**
Our assumed compression ratio for GZIP was pretty far off for CSS. This updates our compression ratios to be more realistic.

**Related Issues/PRs**
closes #7180 


Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 792cd8c84..c3337b04e 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -66,15 +66,26 @@ class UnusedBytes extends Audit {
    * @param {LH.Artifacts.NetworkRequest=} networkRecord
    * @param {number} totalBytes Uncompressed size of the resource
    * @param {LH.Crdp.Page.ResourceType=} resourceType
-   * @param {number=} compressionRatio
    * @return {number}
    */
-  static estimateTransferSize(networkRecord, totalBytes, resourceType, compressionRatio = 0.5) {
+  static estimateTransferSize(networkRecord, totalBytes, resourceType) {
     if (!networkRecord) {
       // We don't know how many bytes this asset used on the network, but we can guess it was
       // roughly the size of the content gzipped.
-      // See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for multipliers
-      return Math.round(totalBytes * compressionRatio);
+      // See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/optimize-encoding-and-transfer for specific CSS/Script examples
+      // See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for fallback multipliers
+      switch (resourceType) {
+        case 'Stylesheet':
+          // Stylesheets tend to compress extremely well.
+          return Math.round(totalBytes * 0.2);
+        case 'Script':
+        case 'Document':
+          // Scripts and HTML compress fairly well too.
+          return Math.round(totalBytes * 0.33);
+        default:
+          // Otherwise we'll just fallback to the average savings in HTTPArchive
+          return Math.round(totalBytes * 0.5);
+      }
     } else if (networkRecord.resourceType === resourceType) {
       // This was a regular standalone asset, just use the transfer size.
       return networkRecord.transferSize || 0;
diff --git a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
index a599dde47..669d52bfb 100644
--- a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
+++ b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
@@ -20,7 +20,7 @@ class ExternalAnchorsUseRelNoopenerAudit extends Audit {
       description: 'Add `rel="noopener"` or `rel="noreferrer"` to any external links to improve ' +
           'performance and prevent security vulnerabilities. ' +
           '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/noopener).',
-      requiredArtifacts: ['URL', 'AnchorsWithNoRelNoopener'],
+      requiredArtifacts: ['URL', 'AnchorElements'],
     };
   }
 
@@ -32,8 +32,10 @@ class ExternalAnchorsUseRelNoopenerAudit extends Audit {
     /** @type {string[]} */
     const warnings = [];
     const pageHost = new URL(artifacts.URL.finalUrl).host;
+    const failingAnchors = artifacts.AnchorElements
+      .filter(anchor => anchor.target === '_blank' && !anchor.rel.includes('noopener') &&
+        !anchor.rel.includes('noreferrer'))
     // Filter usages to exclude anchors that are same origin
-    const failingAnchors = artifacts.AnchorsWithNoRelNoopener
       .filter(anchor => {
         try {
           return new URL(anchor.href).host !== pageHost;
diff --git a/lighthouse-core/audits/seo/link-text.js b/lighthouse-core/audits/seo/link-text.js
index fc95ebd28..6c2cc93b2 100644
--- a/lighthouse-core/audits/seo/link-text.js
+++ b/lighthouse-core/audits/seo/link-text.js
@@ -30,7 +30,7 @@ class LinkText extends Audit {
       failureTitle: 'Links do not have descriptive text',
       description: 'Descriptive link text helps search engines understand your content. ' +
       '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/descriptive-link-text).',
-      requiredArtifacts: ['URL', 'CrawlableLinks'],
+      requiredArtifacts: ['URL', 'AnchorElements'],
     };
   }
 
@@ -39,7 +39,8 @@ class LinkText extends Audit {
    * @return {LH.Audit.Product}
    */
   static audit(artifacts) {
-    const failingLinks = artifacts.CrawlableLinks
+    const failingLinks = artifacts.AnchorElements
+      .filter(link => link.href && !link.rel.includes('nofollow'))
       .filter(link => {
         const href = link.href.toLowerCase();
         if (
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index bf2e8cc95..5a8370e57 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -96,10 +96,10 @@ const defaultConfig = {
       'runtime-exceptions',
       'chrome-console-messages',
       'accessibility',
+      'anchor-elements',
       'image-elements',
       'link-elements',
       'meta-elements',
-      'dobetterweb/anchors-with-no-rel-noopener',
       'dobetterweb/appcache',
       'dobetterweb/doctype',
       'dobetterweb/domstats',
@@ -109,7 +109,6 @@ const defaultConfig = {
       'dobetterweb/response-compression',
       'dobetterweb/tags-blocking-first-paint',
       'seo/font-size',
-      'seo/crawlable-links',
       'seo/hreflang',
       'seo/embedded-content',
       'seo/canonical',
diff --git a/lighthouse-core/gather/gatherers/anchor-elements.js b/lighthouse-core/gather/gatherers/anchor-elements.js
new file mode 100644
index 000000000..3f8b6b80f
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/anchor-elements.js
@@ -0,0 +1,80 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const Gatherer = require('./gatherer.js');
+const pageFunctions = require('../../lib/page-functions.js');
+
+/* eslint-env browser, node */
+
+/**
+ * Function that is stringified and run in the page to collect anchor elements.
+ * Additional complexity is introduced because anchors can be HTML or SVG elements.
+ *
+ * We use this evaluateAsync method because the `node.getAttribute` method doesn't actually normalize
+ * the values like access from JavaScript in-page does.
+ *
+ * @return {LH.Artifacts['AnchorElements']}
+ */
+/* istanbul ignore next */
+function collectAnchorElements() {
+  /** @param {string} url */
+  const resolveURLOrEmpty = url => {
+    try {
+      return new URL(url, window.location.href).href;
+    } catch (_) {
+      return '';
+    }
+  };
+
+  /** @type {Array<HTMLAnchorElement|SVGAElement>} */
+  // @ts-ignore - put into scope via stringification
+  const anchorElements = getElementsInDocument('a'); // eslint-disable-line no-undef
+
+  return anchorElements.map(node => {
+    // @ts-ignore - put into scope via stringification
+    const outerHTML = getOuterHTMLSnippet(node); // eslint-disable-line no-undef
+
+    if (node instanceof HTMLAnchorElement) {
+      return {
+        href: node.href,
+        text: node.innerText, // we don't want to return hidden text, so use innerText
+        rel: node.rel,
+        target: node.target,
+        outerHTML,
+      };
+    }
+
+    return {
+      href: resolveURLOrEmpty(node.href.baseVal),
+      text: node.textContent || '',
+      rel: '',
+      target: node.target.baseVal || '',
+      outerHTML,
+    };
+  });
+}
+
+class AnchorElements extends Gatherer {
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['AnchorElements']>}
+   */
+  async afterPass(passContext) {
+    const driver = passContext.driver;
+    const expression = `(() => {
+      ${pageFunctions.getOuterHTMLSnippetString};
+      ${pageFunctions.getElementsInDocumentString};
+
+      return (${collectAnchorElements})();
+    })()`;
+
+    /** @type {Array<LH.Artifacts.AnchorElement>} */
+    return driver.evaluateAsync(expression, {useIsolation: true});
+  }
+}
+
+module.exports = AnchorElements;
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js b/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js
deleted file mode 100644
index 18ff463b9..000000000
--- a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license Copyright 2016 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-const Gatherer = require('../gatherer');
-const pageFunctions = require('../../../lib/page-functions.js');
-
-class AnchorsWithNoRelNoopener extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['AnchorsWithNoRelNoopener']>}
-   */
-  afterPass(passContext) {
-    const expression = `(function() {
-      ${pageFunctions.getOuterHTMLSnippetString};
-      ${pageFunctions.getElementsInDocumentString}; // define function on page
-      const selector = 'a[target="_blank"]:not([rel~="noopener"]):not([rel~="noreferrer"])';
-      const elements = getElementsInDocument(selector);
-      return elements.map(node => ({
-        href: node.href,
-        rel: node.getAttribute('rel'),
-        target: node.getAttribute('target'),
-        outerHTML: getOuterHTMLSnippet(node),
-      }));
-    })()`;
-
-    return passContext.driver.evaluateAsync(expression);
-  }
-}
-
-module.exports = AnchorsWithNoRelNoopener;
diff --git a/lighthouse-core/gather/gatherers/seo/crawlable-links.js b/lighthouse-core/gather/gatherers/seo/crawlable-links.js
deleted file mode 100644
index fb27136c1..000000000
--- a/lighthouse-core/gather/gatherers/seo/crawlable-links.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-const Gatherer = require('../gatherer');
-const pageFunctions = require('../../../lib/page-functions.js');
-
-class CrawlableLinks extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['CrawlableLinks']>}
-   */
-  async afterPass(passContext) {
-    const expression = `(function() {
-      ${pageFunctions.getElementsInDocumentString}; // define function on page
-      const resolveURLOrNull = url => {
-        try { return new URL(url, window.location.href).href; }
-        catch (_) { return null; }
-      };
-
-      const selector = 'a[href]:not([rel~="nofollow"])';
-      const elements = getElementsInDocument(selector);
-      return elements
-        .map(node => ({
-          href: node.href instanceof SVGAnimatedString ?
-            resolveURLOrNull(node.href.baseVal) :
-            node.href,
-          text: node.href instanceof SVGAnimatedString ?
-            node.textContent :
-            node.innerText,
-        }));
-    })()`;
-
-    /** @type {LH.Artifacts['CrawlableLinks']} */
-    const links = await passContext.driver.evaluateAsync(expression, {useIsolation: true});
-    return links.filter(link => typeof link.href === 'string' && link.href);
-  }
-}
-
-module.exports = CrawlableLinks;
-
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index 73a443747..b4b8ba2c9 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -40,12 +40,12 @@ const DESKTOP_EMULATION_METRICS = {
 
 const NEXUS5X_USERAGENT = {
   // eslint-disable-next-line max-len
-  userAgent: 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3559.0 Mobile Safari/537.36',
+  userAgent: 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3694.0 Mobile Safari/537.36',
 };
 
 const DESKTOP_USERAGENT = {
   // eslint-disable-next-line max-len
-  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3559.0 Safari/537.36',
+  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3694.0 Safari/537.36',
 };
 
 const OFFLINE_METRICS = {
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index a40bc276a..9dca79bb4 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -50,8 +50,8 @@ declare global {
     export interface GathererArtifacts {
       /** The results of running the aXe accessibility tests on the page. */
       Accessibility: Artifacts.Accessibility;
-      /** Information on all anchors in the page that aren't nofollow or noreferrer. */
-      AnchorsWithNoRelNoopener: {href: string; rel: string; target: string, outerHTML: string}[];
+      /** Array of all anchors on the page. */
+      AnchorElements: Artifacts.AnchorElement[];
       /** The value of the page's <html> manifest attribute, or null if not defined */
       AppCacheManifest: string | null;
       /** Array of all URLs cached in CacheStorage. */
@@ -60,8 +60,6 @@ declare global {
       Canonical: (string | null)[];
       /** Console deprecation and intervention warnings logged by Chrome during page load. */
       ChromeConsoleMessages: Crdp.Log.EntryAddedEvent[];
-      /** The href and innerText of all non-nofollow anchors in the page. */
-      CrawlableLinks: {href: string, text: string}[];
       /** CSS coverage information for styles used by page's final state. */
       CSSUsage: {rules: Crdp.CSS.RuleUsage[], stylesheets: Artifacts.CSSStyleSheetInfo[]};
       /** Information on the document's doctype(or null if not present), specifically the name, publicId, and systemId.
@@ -178,6 +176,15 @@ declare global {
         crossOrigin: 'anonymous'|'use-credentials'|null
       }
 
+      /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#Attributes */
+      export interface AnchorElement {
+        rel: string
+        href: string
+        text: string
+        target: string
+        outerHTML: string
+      }
+
       export interface Font {
         display: string;
         family: string;


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 1b75abf0f..40666ee60 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1093,6 +1093,9 @@ Object {
         Object {
           "path": "accessibility",
         },
+        Object {
+          "path": "anchor-elements",
+        },
         Object {
           "path": "image-elements",
         },
@@ -1102,9 +1105,6 @@ Object {
         Object {
           "path": "meta-elements",
         },
-        Object {
-          "path": "dobetterweb/anchors-with-no-rel-noopener",
-        },
         Object {
           "path": "dobetterweb/appcache",
         },
@@ -1132,9 +1132,6 @@ Object {
         Object {
           "path": "seo/font-size",
         },
-        Object {
-          "path": "seo/crawlable-links",
-        },
         Object {
           "path": "seo/hreflang",
         },
diff --git a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
index e355ea648..4b617c90b 100644
--- a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
+++ b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
@@ -23,7 +23,7 @@ function generateInlineStyleWithSize(sizeInBytes, firstContent = '', used = fals
       div.classList.add(className);
       document.body.appendChild(div);
     }
-    sizeInBytes -= rule.length / 3; // 1 byte per character, GZip estimate is 3x
+    sizeInBytes -= rule.length * 0.2; // 1 byte per character, GZip estimate is 20% for Stylesheets
   }
 
   const style = document.createElement('style');
@@ -157,11 +157,11 @@ setTimeout(() => {
 // PASS: unused but too small savings
 generateInlineStyleWithSize(512, '.too-small { background: none; }\n');
 // PASS: used
-generateInlineStyleWithSize(4000, '.mostly-used { background: none; }\n', true);
-// PASSWARN: unused and a bit of savings
-generateInlineStyleWithSize(2000, '.kinda-unused { background: none; }\n');
+generateInlineStyleWithSize(5000, '.mostly-used { background: none; }\n', true);
+// FAIL: unused and a bit of savings
+generateInlineStyleWithSize(5000, '.kinda-unused { background: none; }\n');
 // FAIL: unused and lots of savings
-generateInlineStyleWithSize(24000, '.definitely-unused { background: none; }\n');
+generateInlineStyleWithSize(30000, '.definitely-unused { background: none; }\n');
 </script>
 
 <!-- Ensure the page takes at least 7 seconds and we don't exit before the lazily loaded image -->
diff --git a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
index d4a379638..d26e7cf15 100644
--- a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
@@ -17,7 +17,7 @@ module.exports = [
         details: {
           overallSavingsBytes: '>17000',
           items: {
-            length: 1,
+            length: 2,
           },
         },
       },
@@ -33,7 +33,7 @@ module.exports = [
       },
       'unused-css-rules': {
         details: {
-          overallSavingsBytes: '>39000',
+          overallSavingsBytes: '>35000',
           items: {
             length: 2,
           },
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index 135c661fb..d000649f6 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -53,9 +53,11 @@ describe('Byte efficiency base audit', () => {
   describe('#estimateTransferSize', () => {
     const estimate = ByteEfficiencyAudit.estimateTransferSize;
 
-    it('should estimate by compression ratio when no network record available', () => {
-      const result = estimate(undefined, 1000, '', 0.345);
-      assert.equal(result, 345);
+    it('should estimate by resource type compression ratio when no network info available', () => {
+      assert.equal(estimate(undefined, 1000, 'Stylesheet'), 200);
+      assert.equal(estimate(undefined, 1000, 'Script'), 330);
+      assert.equal(estimate(undefined, 1000, 'Document'), 330);
+      assert.equal(estimate(undefined, 1000, ''), 500);
     });
 
     it('should return transferSize when asset matches', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
index 3a5304cf6..693ea43e7 100644
--- a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
@@ -166,8 +166,8 @@ describe('Best Practices: unused css rules audit', () => {
     it('fails when lots of rules are unused', () => {
       return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
-          {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 11}, // 44 * 1 / 4
-          {styleSheetId: 'b', used: true, startOffset: 0, endOffset: 6000}, // 4000 * 3 / 2
+          {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 11}, // 44 * 25% = 11
+          {styleSheetId: 'b', used: true, startOffset: 0, endOffset: 60000}, // 40000 * 3 * 50% = 60000
         ], stylesheets: [
           {
             header: {styleSheetId: 'a', sourceURL: 'file://a.css'},
@@ -175,7 +175,7 @@ describe('Best Practices: unused css rules audit', () => {
           },
           {
             header: {styleSheetId: 'b', sourceURL: 'file://b.css'},
-            content: `${generate('123', 4000)}`,
+            content: `${generate('123', 40000)}`,
           },
           {
             header: {styleSheetId: 'c', sourceURL: ''},
@@ -185,7 +185,7 @@ describe('Best Practices: unused css rules audit', () => {
       }), networkRecords).then(result => {
         assert.equal(result.items.length, 2);
         assert.equal(result.items[0].totalBytes, 10 * 1024);
-        assert.equal(result.items[1].totalBytes, 6000);
+        assert.equal(result.items[1].totalBytes, 40000 * 3 * 0.2);
         assert.equal(result.items[0].wastedPercent, 75);
         assert.equal(result.items[1].wastedPercent, 50);
       });
diff --git a/lighthouse-core/test/audits/dobetterweb/external-anchors-use-rel-noopener-test.js b/lighthouse-core/test/audits/dobetterweb/external-anchors-use-rel-noopener-test.js
index 5d1efdb6a..f852eb230 100644
--- a/lighthouse-core/test/audits/dobetterweb/external-anchors-use-rel-noopener-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/external-anchors-use-rel-noopener-test.js
@@ -16,9 +16,36 @@ const URL = 'https://google.com/test';
 describe('External anchors use rel="noopener"', () => {
   it('passes when links are from same hosts as the page host', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: 'https://google.com/test'},
-        {href: 'https://google.com/test1'},
+      AnchorElements: [
+        {href: 'https://google.com/test', target: '_blank', rel: ''},
+        {href: 'https://google.com/test1', target: '_blank', rel: ''},
+      ],
+      URL: {finalUrl: URL},
+    });
+    assert.equal(auditResult.rawValue, true);
+    assert.equal(auditResult.details.items.length, 0);
+    assert.equal(auditResult.details.items.length, 0);
+  });
+
+  it('passes when links have a valid rel', () => {
+    const auditResult = ExternalAnchorsAudit.audit({
+      AnchorElements: [
+        {href: 'https://other.com/test', target: '_blank', rel: 'nofollow noopener'},
+        {href: 'https://other.com/test1', target: '_blank', rel: 'noreferrer'},
+        {href: 'https://other.com/test2', target: '_blank', rel: 'noopener'},
+      ],
+      URL: {finalUrl: URL},
+    });
+    assert.equal(auditResult.rawValue, true);
+    assert.equal(auditResult.details.items.length, 0);
+    assert.equal(auditResult.details.items.length, 0);
+  });
+
+  it('passes when links do not use target=_blank', () => {
+    const auditResult = ExternalAnchorsAudit.audit({
+      AnchorElements: [
+        {href: 'https://other.com/test', rel: ''},
+        {href: 'https://other.com/test1', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
@@ -29,9 +56,9 @@ describe('External anchors use rel="noopener"', () => {
 
   it('passes when links have javascript in href attribute', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: 'javascript:void(0)'},
-        {href: 'JAVASCRIPT:void(0)'},
+      AnchorElements: [
+        {href: 'javascript:void(0)', target: '_blank', rel: ''},
+        {href: 'JAVASCRIPT:void(0)', target: '_blank', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
@@ -41,9 +68,9 @@ describe('External anchors use rel="noopener"', () => {
 
   it('passes when links have mailto in href attribute', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: 'mailto:inbox@email.com'},
-        {href: 'MAILTO:INBOX@EMAIL.COM'},
+      AnchorElements: [
+        {href: 'mailto:inbox@email.com', target: '_blank', rel: ''},
+        {href: 'MAILTO:INBOX@EMAIL.COM', target: '_blank', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
@@ -53,9 +80,9 @@ describe('External anchors use rel="noopener"', () => {
 
   it('fails when links are from different hosts than the page host', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: 'https://example.com/test'},
-        {href: 'https://example.com/test1'},
+      AnchorElements: [
+        {href: 'https://example.com/test', target: '_blank', rel: 'nofollow'},
+        {href: 'https://example.com/test1', target: '_blank', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
@@ -66,8 +93,8 @@ describe('External anchors use rel="noopener"', () => {
 
   it('fails when links have no href attribute', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: ''},
+      AnchorElements: [
+        {href: '', target: '_blank', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
@@ -79,11 +106,11 @@ describe('External anchors use rel="noopener"', () => {
 
   it('fails when links have href attribute starting with a protocol', () => {
     const auditResult = ExternalAnchorsAudit.audit({
-      AnchorsWithNoRelNoopener: [
-        {href: 'http://'},
-        {href: 'http:'},
-        {href: 'https://'},
-        {href: 'https:'},
+      AnchorElements: [
+        {href: 'http://', target: '_blank', rel: ''},
+        {href: 'http:', target: '_blank', rel: ''},
+        {href: 'https://', target: '_blank', rel: ''},
+        {href: 'https:', target: '_blank', rel: ''},
       ],
       URL: {finalUrl: URL},
     });
diff --git a/lighthouse-core/test/audits/seo/link-text-test.js b/lighthouse-core/test/audits/seo/link-text-test.js
index 6fce06b0b..86ac948a8 100644
--- a/lighthouse-core/test/audits/seo/link-text-test.js
+++ b/lighthouse-core/test/audits/seo/link-text-test.js
@@ -12,15 +12,15 @@ const assert = require('assert');
 
 describe('SEO: link text audit', () => {
   it('fails when link with non descriptive text is found', () => {
-    const invalidLink = {href: 'https://example.com/otherpage.html', text: 'click here'};
+    const invalidLink = {href: 'https://example.com/otherpage.html', text: 'click here', rel: ''};
     const artifacts = {
       URL: {
         finalUrl: 'https://example.com/page.html',
       },
-      CrawlableLinks: [
-        {href: 'https://example.com/otherpage.html', text: 'legit link text'},
+      AnchorElements: [
+        {href: 'https://example.com/otherpage.html', text: 'legit link text', rel: ''},
         invalidLink,
-        {href: 'https://example.com/otherpage.html', text: 'legit link text'},
+        {href: 'https://example.com/otherpage.html', text: 'legit link text', rel: ''},
       ],
     };
 
@@ -36,11 +36,11 @@ describe('SEO: link text audit', () => {
       URL: {
         finalUrl: 'https://example.com/page.html',
       },
-      CrawlableLinks: [
-        {href: 'https://example.com/otherpage.html', text: 'legit link text'},
-        {href: 'https://example.com/page.html', text: 'click here'},
-        {href: 'https://example.com/page.html#test', text: 'click here'},
-        {href: 'https://example.com/otherpage.html', text: 'legit link text'},
+      AnchorElements: [
+        {href: 'https://example.com/otherpage.html', text: 'legit link text', rel: ''},
+        {href: 'https://example.com/page.html', text: 'click here', rel: ''},
+        {href: 'https://example.com/page.html#test', text: 'click here', rel: ''},
+        {href: 'https://example.com/otherpage.html', text: 'legit link text', rel: ''},
       ],
     };
 
@@ -53,10 +53,10 @@ describe('SEO: link text audit', () => {
       URL: {
         finalUrl: 'https://example.com/page.html',
       },
-      CrawlableLinks: [
-        {href: 'javascript:alert(1)', text: 'click here'},
-        {href: 'JavaScript:window.location="/otherpage.html"', text: 'click here'},
-        {href: 'JAVASCRIPT:void(0)', text: 'click here'},
+      AnchorElements: [
+        {href: 'javascript:alert(1)', text: 'click here', rel: ''},
+        {href: 'JavaScript:window.location="/otherpage.html"', text: 'click here', rel: ''},
+        {href: 'JAVASCRIPT:void(0)', text: 'click here', rel: ''},
       ],
     };
 
@@ -69,9 +69,37 @@ describe('SEO: link text audit', () => {
       URL: {
         finalUrl: 'https://example.com/page.html',
       },
-      CrawlableLinks: [
-        {href: 'mailto:info@example.com', text: 'click here'},
-        {href: 'mailto:mailmaster@localhost', text: 'click here'},
+      AnchorElements: [
+        {href: 'mailto:info@example.com', text: 'click here', rel: ''},
+        {href: 'mailto:mailmaster@localhost', text: 'click here', rel: ''},
+      ],
+    };
+
+    const auditResult = LinkTextAudit.audit(artifacts);
+    assert.equal(auditResult.rawValue, true);
+  });
+
+  it('ignores links with no href', () => {
+    const artifacts = {
+      URL: {
+        finalUrl: 'https://example.com/page.html',
+      },
+      AnchorElements: [
+        {href: '', text: 'click here', rel: ''},
+      ],
+    };
+
+    const auditResult = LinkTextAudit.audit(artifacts);
+    assert.equal(auditResult.rawValue, true);
+  });
+
+  it('ignores links with nofollow', () => {
+    const artifacts = {
+      URL: {
+        finalUrl: 'https://example.com/page.html',
+      },
+      AnchorElements: [
+        {href: '', text: 'click here', rel: 'noopener nofollow'},
       ],
     };
 
@@ -84,10 +112,10 @@ describe('SEO: link text audit', () => {
       URL: {
         finalUrl: 'https://example.com/page.html',
       },
-      CrawlableLinks: [
-        {href: 'https://example.com/otherpage.html', text: 'legit link text'},
-        {href: 'http://example.com/page.html?test=test', text: 'legit link text'},
-        {href: 'file://Users/user/Desktop/file.png', text: 'legit link text'},
+      AnchorElements: [
+        {href: 'https://example.com/otherpage.html', text: 'legit link text', rel: ''},
+        {href: 'http://example.com/page.html?test=test', text: 'legit link text', rel: ''},
+        {href: 'file://Users/user/Desktop/file.png', text: 'legit link text', rel: ''},
       ],
     };
 
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 2a3807764..548581f7c 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -2229,39 +2229,52 @@
       "col": 45
     }
   ],
-  "AnchorsWithNoRelNoopener": [
+  "AnchorElements": [
     {
       "href": "https://www.google.com/",
       "outerHTML": "<a href=\"https://www.google.com/\" target=\"blank\">Hello</a>",
-      "rel": null,
-      "target": "_blank"
+      "rel": "",
+      "target": "_blank",
+      "text": "external link"
     },
     {
       "href": "",
       "outerHTML": "<a target=\"blank\">Hello</a>",
-      "rel": null,
-      "target": "_blank"
+      "rel": "",
+      "target": "_blank",
+      "text": "Hello"
+    },
+    {
+      "href": "https://www.google.com/",
+      "outerHTML": "<a rel=\"noopener\" href=\"https://www.google.com/\" target=\"blank\">Hello</a>",
+      "rel": "noopener",
+      "target": "_blank",
+      "text": "external link that uses noopener"
     },
     {
       "href": "https://www.google.com/",
       "outerHTML": "<a rel=\"nofollow\" href=\"https://www.google.com/\" target=\"blank\">Hello</a>",
       "rel": "nofollow",
-      "target": "_blank"
+      "target": "_blank",
+      "text": "external link that uses nofollow"
     },
     {
       "href": "http://localhost:10200/dobetterweb/doesnotexist",
-      "rel": null,
-      "target": "_blank"
+      "rel": "",
+      "target": "_blank",
+      "text": "internal link is ok"
     },
     {
       "href": "javascript:void(0)",
-      "rel": null,
-      "target": "_blank"
+      "rel": "",
+      "target": "_blank",
+      "text": ""
     },
     {
       "href": "mailto:inbox@email.com",
-      "rel": null,
-      "target": "_blank"
+      "rel": "",
+      "target": "_blank",
+      "text": ""
     }
   ],
   "AppCacheManifest": "clock.appcache",
@@ -2488,28 +2501,6 @@
     "visitedTextLength": 228,
     "totalTextLength": 228
   },
-  "CrawlableLinks": [
-    {
-      "href": "https://www.google.com/",
-      "text": "external link"
-    },
-    {
-      "href": "https://www.google.com/",
-      "text": "external link that uses rel noopener"
-    },
-    {
-      "href": "http://localhost:10200/dobetterweb/doesnotexist",
-      "text": "internal link is ok"
-    },
-    {
-      "href": "javascript:void(0)",
-      "text": ""
-    },
-    {
-      "href": "mailto:inbox@email.com",
-      "text": ""
-    }
-  ],
   "Hreflang": [],
   "EmbeddedContent": [],
   "Canonical": [],