Repository: GoogleChrome/lighthouse
Pull request #7477 (2019-03-12 21:45:35)

core: remove hreflang gatherer
**Summary**
Deletes the Hreflang gatherer and refactors the audit to use `LinkElements`.

**Related Issues/PRs**
#6747 



Patch:
diff --git a/build/dt-report-generator-bundle.js b/build/dt-report-generator-bundle.js
new file mode 100644
index 000000000..0dd028b4f
--- /dev/null
+++ b/build/dt-report-generator-bundle.js
@@ -0,0 +1,19 @@
+/**
+ * @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 browserify = require('browserify');
+const fs = require('fs');
+
+const distDir = __dirname + '/../dist';
+const outFile = `${distDir}/report-generator.js`;
+const generatorFilename = `./lighthouse-core/report/report-generator.js`;
+browserify(generatorFilename, {standalone: 'ReportGenerator'})
+  .transform('brfs')
+  .bundle((err, src) => {
+    if (err) throw err;
+    fs.writeFileSync(outFile, src.toString());
+  });
diff --git a/clients/lightrider-entry.js b/clients/lightrider-entry.js
index a942ac292..36bc65210 100644
--- a/clients/lightrider-entry.js
+++ b/clients/lightrider-entry.js
@@ -31,7 +31,7 @@ async function runLighthouseInLR(connection, url, flags, lrOpts) {
   const {lrDevice, categoryIDs, logAssets, keepRawValues} = lrOpts;
 
   // Certain fixes need to kick in under LR, see https://github.com/GoogleChrome/lighthouse/issues/5839
-  global.isLightRider = true;
+  global.isLightrider = true;
 
   // disableStorageReset because it causes render server hang
   flags.disableStorageReset = true;
diff --git a/lighthouse-core/audits/dobetterweb/uses-http2.js b/lighthouse-core/audits/dobetterweb/uses-http2.js
index 998167b6f..6ca132cc7 100644
--- a/lighthouse-core/audits/dobetterweb/uses-http2.js
+++ b/lighthouse-core/audits/dobetterweb/uses-http2.js
@@ -44,6 +44,9 @@ class UsesHTTP2Audit extends Audit {
       const seenURLs = new Set();
       // Filter requests that are on the same host as the page and not over h2.
       const resources = networkRecords.filter(record => {
+        // check if record is not served through the service worker, servicer worker uses http/1.1 as a protocol
+        // these can generate false positives (bug: https://github.com/GoogleChrome/lighthouse/issues/7158)
+        if (record.fetchedViaServiceWorker) return false;
         // test the protocol first to avoid (potentially) expensive URL parsing
         const isOldHttp = /HTTP\/[01][.\d]?/i.test(record.protocol);
         if (!isOldHttp) return false;
diff --git a/lighthouse-core/audits/image-aspect-ratio.js b/lighthouse-core/audits/image-aspect-ratio.js
index a3fc6bdec..d8764278d 100644
--- a/lighthouse-core/audits/image-aspect-ratio.js
+++ b/lighthouse-core/audits/image-aspect-ratio.js
@@ -72,10 +72,13 @@ class ImageAspectRatio extends Audit {
     /** @type {Array<{url: string, displayedAspectRatio: string, actualAspectRatio: string, doRatiosMatch: boolean}>} */
     const results = [];
     images.filter(image => {
+      // - filter out css background images since we don't have a reliable way to tell if it's a
+      //   sprite sheet, repeated for effect, etc
       // - filter out images that don't have following properties:
       //   networkRecord, width, height, images that use `object-fit`: `cover` or `contain`
       // - filter all svgs as they have no natural dimensions to audit
-      return image.mimeType &&
+      return !image.isCss &&
+        image.mimeType &&
         image.mimeType !== 'image/svg+xml' &&
         image.naturalHeight > 5 &&
         image.naturalWidth > 5 &&
diff --git a/lighthouse-core/audits/seo/hreflang.js b/lighthouse-core/audits/seo/hreflang.js
index 851fa1452..6ae2dcf89 100644
--- a/lighthouse-core/audits/seo/hreflang.js
+++ b/lighthouse-core/audits/seo/hreflang.js
@@ -6,10 +6,7 @@
 'use strict';
 
 const Audit = require('../audit');
-const LinkHeader = require('http-link-header');
-const MainResource = require('../../computed/main-resource.js');
 const VALID_LANGS = importValidLangs();
-const LINK_HEADER = 'link';
 const NO_LANGUAGE = 'x-default';
 const i18n = require('../../lib/i18n/i18n.js');
 
@@ -61,17 +58,6 @@ function isValidHreflang(hreflang) {
   return VALID_LANGS.includes(lang.toLowerCase());
 }
 
-/**
- * @param {string} headerValue
- * @returns {boolean}
- */
-function headerHasValidHreflangs(headerValue) {
-  const linkHeader = LinkHeader.parse(headerValue);
-
-  return linkHeader.get('rel', 'alternate')
-    .every(link => !!link.hreflang && isValidHreflang(link.hreflang));
-}
-
 class Hreflang extends Audit {
   /**
    * @return {LH.Audit.Meta}
@@ -82,52 +68,47 @@ class Hreflang extends Audit {
       title: str_(UIStrings.title),
       failureTitle: str_(UIStrings.failureTitle),
       description: str_(UIStrings.description),
-      requiredArtifacts: ['Hreflang', 'URL'],
+      requiredArtifacts: ['LinkElements', 'URL'],
     };
   }
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @param {LH.Audit.Context} context
-   * @return {Promise<LH.Audit.Product>}
+   * @return {LH.Audit.Product}
    */
-  static audit(artifacts, context) {
-    const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
-    const URL = artifacts.URL;
+  static audit(artifacts) {
+    /** @type {Array<{source: string|{type: 'node', snippet: string}}>} */
+    const invalidHreflangs = [];
+
+    for (const link of artifacts.LinkElements) {
+      if (link.rel !== 'alternate') continue;
+      if (!link.hreflang || isValidHreflang(link.hreflang)) continue;
+      if (link.source === 'body') continue;
+
+      if (link.source === 'head') {
+        invalidHreflangs.push({
+          source: {
+            type: 'node',
+            snippet: `<link rel="alternate" hreflang="${link.hreflang}" href="${link.href}" />`,
+          },
+        });
+      } else if (link.source === 'headers') {
+        invalidHreflangs.push({
+          source: `Link: <${link.href}>; rel="alternate"; hreflang="${link.hreflang}"`,
+        });
+      }
+    }
+
+    /** @type {LH.Audit.Details.Table['headings']} */
+    const headings = [
+      {key: 'source', itemType: 'code', text: 'Source'},
+    ];
+    const details = Audit.makeTableDetails(headings, invalidHreflangs);
 
-    return MainResource.request({devtoolsLog, URL}, context)
-      .then(mainResource => {
-        /** @type {Array<{source: string|{type: 'node', snippet: string}}>} */
-        const invalidHreflangs = [];
-
-        if (artifacts.Hreflang) {
-          artifacts.Hreflang.forEach(({href, hreflang}) => {
-            if (!isValidHreflang(hreflang)) {
-              invalidHreflangs.push({
-                source: {
-                  type: 'node',
-                  snippet: `<link name="alternate" hreflang="${hreflang}" href="${href}" />`,
-                },
-              });
-            }
-          });
-        }
-
-        mainResource.responseHeaders && mainResource.responseHeaders
-          .filter(h => h.name.toLowerCase() === LINK_HEADER && !headerHasValidHreflangs(h.value))
-          .forEach(h => invalidHreflangs.push({source: `${h.name}: ${h.value}`}));
-
-        /** @type {LH.Audit.Details.Table['headings']} */
-        const headings = [
-          {key: 'source', itemType: 'code', text: 'Source'},
-        ];
-        const details = Audit.makeTableDetails(headings, invalidHreflangs);
-
-        return {
-          rawValue: invalidHreflangs.length === 0,
-          details,
-        };
-      });
+    return {
+      rawValue: invalidHreflangs.length === 0,
+      details,
+    };
   }
 }
 
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 806e8c9d4..dbf2c822f 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -117,7 +117,6 @@ const defaultConfig = {
       'dobetterweb/response-compression',
       'dobetterweb/tags-blocking-first-paint',
       'seo/font-size',
-      'seo/hreflang',
       'seo/embedded-content',
       'seo/robots-txt',
       'seo/tap-targets',
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 6237ff706..caf31fe9c 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -128,7 +128,7 @@ class GatherRunner {
     } catch (err) {
       // Ignore disconnecting error if browser was already closed.
       // See https://github.com/GoogleChrome/lighthouse/issues/1583
-      if (!(/close\/.*status: 500$/.test(err.message))) {
+      if (!(/close\/.*status: (500|404)$/.test(err.message))) {
         log.error('GatherRunner disconnect', err.message);
       }
     }
diff --git a/lighthouse-core/gather/gatherers/seo/hreflang.js b/lighthouse-core/gather/gatherers/seo/hreflang.js
deleted file mode 100644
index 162a5daa2..000000000
--- a/lighthouse-core/gather/gatherers/seo/hreflang.js
+++ /dev/null
@@ -1,35 +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');
-
-class Hreflang extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['Hreflang']>}
-   */
-  afterPass(passContext) {
-    const driver = passContext.driver;
-
-    return driver.querySelectorAll('head link[rel="alternate" i][hreflang]')
-      .then(nodes => Promise.all(nodes.map(node =>
-          Promise.all([node.getAttribute('href'), node.getAttribute('hreflang')]))
-        )
-      ).then(attributeValues => attributeValues &&
-        attributeValues.map(values => {
-          const [href, hreflang] = values;
-          return {
-            href: href || '',
-            hreflang: hreflang || '',
-          };
-        })
-      );
-  }
-}
-
-module.exports = Hreflang;
-
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index 945146f40..6314c60f7 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -225,13 +225,13 @@ function elementIsInTextBlock(element) {
  * @returns {boolean}
  */
 /* istanbul ignore next */
-function elementIsPositionFixedOrAbsolute(element) {
+function elementIsPositionFixedStickyOrAbsolute(element) {
   const {position} = getComputedStyle(element);
-  if (position === 'fixed' || position === 'absolute') {
+  if (position === 'fixed' || position === 'absolute' || position === 'sticky') {
     return true;
   }
   if (element.parentElement) {
-    return elementIsPositionFixedOrAbsolute(element.parentElement);
+    return elementIsPositionFixedStickyOrAbsolute(element.parentElement);
   }
   return false;
 }
@@ -273,7 +273,7 @@ function gatherTapTargets() {
       // in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
       return;
     }
-    if (elementIsPositionFixedOrAbsolute(tapTargetElement)) {
+    if (elementIsPositionFixedStickyOrAbsolute(tapTargetElement)) {
       // Absolutely positioned elements might not be visible if they have a lower z-index
       // than other tap targets, but if we don't ignore them we can get false failures.
       //
@@ -311,7 +311,7 @@ class TapTargets extends Gatherer {
       const tapTargetsSelector = "${tapTargetsSelector}";
       ${pageFunctions.getElementsInDocumentString};
       ${filterClientRectsWithinAncestorsVisibleScrollArea.toString()};
-      ${elementIsPositionFixedOrAbsolute.toString()};
+      ${elementIsPositionFixedStickyOrAbsolute.toString()};
       ${elementIsVisible.toString()};
       ${elementHasAncestorTapTarget.toString()};
       ${getVisibleClientRects.toString()};
diff --git a/lighthouse-core/lib/network-request.js b/lighthouse-core/lib/network-request.js
index 8c5fc69ec..687924e0b 100644
--- a/lighthouse-core/lib/network-request.js
+++ b/lighthouse-core/lib/network-request.js
@@ -67,6 +67,7 @@ module.exports = class NetworkRequest {
     /** @type {number} */
     this.responseReceivedTime = -1;
 
+    // Go read the comment on _updateTransferSizeForLightrider.
     this.transferSize = 0;
     this.resourceSize = 0;
     this.fromDiskCache = false;
@@ -191,7 +192,7 @@ module.exports = class NetworkRequest {
     }
 
     this._updateResponseReceivedTimeIfNecessary();
-    this._updateTransferSizeForLightRiderIfNecessary();
+    this._updateTransferSizeForLightrider();
   }
 
   /**
@@ -209,6 +210,7 @@ module.exports = class NetworkRequest {
     this.localizedFailDescription = data.errorText;
 
     this._updateResponseReceivedTimeIfNecessary();
+    this._updateTransferSizeForLightrider();
   }
 
   /**
@@ -261,8 +263,6 @@ module.exports = class NetworkRequest {
 
     if (this.fromMemoryCache) this.timing = undefined;
     if (this.timing) this._recomputeTimesWithResourceTiming(this.timing);
-
-    this._updateTransferSizeForLightRiderIfNecessary();
   }
 
   /**
@@ -297,12 +297,24 @@ module.exports = class NetworkRequest {
 
   /**
    * LR loses transfer size information, but passes it in the 'X-TotalFetchedSize' header.
+   * 'X-TotalFetchedSize' is the canonical transfer size in LR. Nothing should supersede it.
+   *
+   * The total length of the encoded data is spread out among multiple events. The sum of the
+   * values in onResponseReceived and all the onDataReceived events typically equals the value
+   * seen on the onLoadingFinished event. In <1% of cases we see the values differ. As we process
+   * onResponseReceived and onDataReceived we accumulate the total encodedDataLength. When we
+   * process onLoadingFinished, we override the accumulated total. We do this so that if the
+   * request is aborted or fails, we still get a value via the accumulation.
+   *
+   * In Lightrider, due to instrumentation limitations, our values for encodedDataLength are bogus
+   * and not valid. However the resource's true encodedDataLength/transferSize is shared via a
+   * special response header, X-TotalFetchedSize. In this situation, we read this value from
+   * responseReceived, use it for the transferSize and ignore the encodedDataLength values in
+   * both dataReceived and loadingFinished.
    */
-  _updateTransferSizeForLightRiderIfNecessary() {
-    // Bail if we're not in LightRider, this only applies there.
-    if (!global.isLightRider) return;
+  _updateTransferSizeForLightrider() {
     // Bail if we somehow already have transfer size data.
-    if (this.transferSize) return;
+    if (!global.isLightrider) return;
 
     const totalFetchedSize = this.responseHeaders.find(item => item.name === 'X-TotalFetchedSize');
     // Bail if the header was missing.
diff --git a/lighthouse-core/report/html/renderer/dom.js b/lighthouse-core/report/html/renderer/dom.js
index 4d178fcdc..dee3fa981 100644
--- a/lighthouse-core/report/html/renderer/dom.js
+++ b/lighthouse-core/report/html/renderer/dom.js
@@ -27,6 +27,8 @@ class DOM {
   constructor(document) {
     /** @type {Document} */
     this._document = document;
+    /** @type {string} */
+    this._lighthouseChannel = 'unknown';
   }
 
   /**
@@ -125,11 +127,19 @@ class DOM {
 
       // Append link if there are any.
       if (linkText && linkHref) {
+        const url = new URL(linkHref);
+
+        const DEVELOPERS_GOOGLE_ORIGIN = 'https://developers.google.com';
+        if (url.origin === DEVELOPERS_GOOGLE_ORIGIN) {
+          url.searchParams.set('utm_source', 'lighthouse');
+          url.searchParams.set('utm_medium', this._lighthouseChannel);
+        }
+
         const a = this.createElement('a');
         a.rel = 'noopener';
         a.target = '_blank';
         a.textContent = linkText;
-        a.href = (new URL(linkHref)).href;
+        a.href = url.href;
         element.appendChild(a);
       }
     }
@@ -159,6 +169,14 @@ class DOM {
     return element;
   }
 
+  /**
+   * The channel to use for UTM data when rendering links to the documentation.
+   * @param {string} lighthouseChannel
+   */
+  setLighthouseChannel(lighthouseChannel) {
+    this._lighthouseChannel = lighthouseChannel;
+  }
+
   /**
    * @return {Document}
    */
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 325c27dc5..7a997ee26 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -47,6 +47,8 @@ class ReportRenderer {
     // Mutate the UIStrings if necessary (while saving originals)
     const originalUIStrings = JSON.parse(JSON.stringify(Util.UIStrings));
 
+    this._dom.setLighthouseChannel(result.configSettings.channel || 'unknown');
+
     const report = Util.prepareReportResult(result);
 
     container.textContent = ''; // Remove previous report.
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 7915e7bd0..0c579479e 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -801,7 +801,9 @@
 }
 
 .lh-scores-header .lh-gauge--pwa__wrapper {
-  border-left: 1px solid var(--report-secondary-border-color)
+  border-left: 1px solid var(--report-secondary-border-color);
+  /* Can remove when this bug is resolved: https://bugs.chromium.org/p/chromium/issues/detail?id=942097 */
+  will-change: transform;
 }
 
 .lh-scorescale {
diff --git a/lighthouse-core/report/report-generator.js b/lighthouse-core/report/report-generator.js
index 09d653a30..61561c6d2 100644
--- a/lighthouse-core/report/report-generator.js
+++ b/lighthouse-core/report/report-generator.js
@@ -8,6 +8,10 @@
 const htmlReportAssets = require('./html/html-report-assets');
 
 class ReportGenerator {
+  static getAssets() {
+    return htmlReportAssets;
+  }
+
   /**
    * Replaces all the specified strings in source without serial replacements.
    * @param {string} source
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 78e879f96..b33714397 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -37,9 +37,12 @@ lh_bg_js="dist/lighthouse-dt-bundle.js"
 lh_worker_dir="$frontend_dir/audits2_worker/lighthouse"
 
 # copy report files
-cp -pPR $report_dir/{report-styles.css,templates.html,renderer} "$fe_lh_dir"
+cp -pPR $report_dir/renderer "$fe_lh_dir"
 echo -e "\033[32m ✓\033[39m Report renderer files copied."
 
 # copy lighthouse-dt-bundle (potentially stale)
 cp -pPR "$lh_bg_js" "$lh_worker_dir/lighthouse-dt-bundle.js"
 echo -e "\033[96m ✓\033[39m (Potentially stale) lighthouse-dt-bundle copied."
+
+# copy report generator
+cp dist/report-generator.js $fe_lh_dir
diff --git a/package.json b/package.json
index 3a686223d..0ac570027 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
     "build-all:task": "yarn build-extension & yarn build-devtools & yarn build-lr & yarn build-viewer & wait",
     "build-all:task:windows": "yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer",
     "build-extension": "node ./build/build-extension.js",
-    "build-devtools": "node ./build/build-bundle.js clients/devtools-entry.js dist/lighthouse-dt-bundle.js",
+    "build-devtools": "node ./build/build-bundle.js clients/devtools-entry.js dist/lighthouse-dt-bundle.js && node ./build/dt-report-generator-bundle.js",
     "build-lr": "node ./build/build-bundle.js clients/lightrider-entry.js dist/lighthouse-lr-bundle.js",
     "build-viewer": "node ./build/build-viewer.js",
     "clean": "rimraf dist proto/scripts/*.json proto/scripts/*_pb2.* proto/scripts/*_pb.* proto/scripts/__pycache__ proto/scripts/*.pyc *.report.html *.report.dom.html *.report.json *.devtoolslog.json *.trace.json || true",
diff --git a/third-party/snyk/snapshot.json b/third-party/snyk/snapshot.json
index 5c67d2de5..19702dea0 100644
--- a/third-party/snyk/snapshot.json
+++ b/third-party/snyk/snapshot.json
@@ -57,7 +57,7 @@
       {"id":"npm:highcharts:20180225","severity":"high","semver":{"vulnerable":["<6.1.0"]}}
     ],
     "jquery":[
-      {"id":"npm:jquery:20160529","severity":"low","semver":{"vulnerable":["=3.0.0-rc.1"]}},
+      {"id":"npm:jquery:20160529","severity":"low","semver":{"vulnerable":["=3.0.0-rc1"]}},
       {"id":"npm:jquery:20150627","severity":"medium","semver":{"vulnerable":["<1.12.2",">=1.12.3 <2.2.2",">=2.2.3 <3.0.0"]}},
       {"id":"npm:jquery:20140902","severity":"medium","semver":{"vulnerable":[">=1.4.2 <1.6.2"]}},
       {"id":"npm:jquery:20120206","severity":"medium","semver":{"vulnerable":["<1.9.0 >=1.7.1"]}},
diff --git a/types/node.d.ts b/types/node.d.ts
index d127580fc..fc365d6e5 100644
--- a/types/node.d.ts
+++ b/types/node.d.ts
@@ -6,6 +6,6 @@
 
 declare module NodeJS {
   interface Global {
-    isLightRider?: boolean;
+    isLightrider?: boolean;
   }
 }


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 e5e6453d9..18ff09255 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1126,9 +1126,6 @@ Object {
         Object {
           "path": "seo/font-size",
         },
-        Object {
-          "path": "seo/hreflang",
-        },
         Object {
           "path": "seo/embedded-content",
         },
diff --git a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
index 9db7c9934..141011099 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
@@ -26,6 +26,9 @@
 <body>
   <!-- Big tap target, but it's invisible because it's behind the main content div -->
   <a style="background: red; position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: -1;"></a>
+
+  <a style="background: red; position: sticky; top: 0; left: 0; display: block; width: 100vw; height: 40px; z-index: 1;">sticky</a>
+
   <div style="background: white; padding: 20px;">
     <h1>SEO Tap targets</h1>
 
@@ -108,13 +111,19 @@
     </div>
 
     <!-- Links in text blocks are exempted from size/overlap requirements and should not fail -->
-    <p style="width: 100px">
+    <p style="width: 100px;margin-bottom: 30px;">
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
     </p>
+
+    <a style="background: #afa; display: block; width: 100%; height: 800px;">
+      This link is intentionally placed at the bottom of the page, so that when we've scrolled
+      to the bottom of the page it overlaps with the sticky header. (Should not fail though
+      because the overlap depends on the current scroll position.)
+    </a>
   </div>
 </body>
 </html>
diff --git a/lighthouse-cli/test/fixtures/static-server.js b/lighthouse-cli/test/fixtures/static-server.js
index 9e5b98bca..e11084e6b 100644
--- a/lighthouse-cli/test/fixtures/static-server.js
+++ b/lighthouse-cli/test/fixtures/static-server.js
@@ -69,6 +69,12 @@ function requestHandler(request, response) {
       headers['Content-Type'] = 'text/css';
     } else if (filePath.endsWith('.svg')) {
       headers['Content-Type'] = 'image/svg+xml';
+    } else if (filePath.endsWith('.png')) {
+      headers['Content-Type'] = 'image/png';
+    } else if (filePath.endsWith('.gif')) {
+      headers['Content-Type'] = 'image/gif';
+    } else if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) {
+      headers['Content-Type'] = 'image/jpeg';
     }
 
     let delay = 0;
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 1d0f8d70f..6fe1913f9 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -184,7 +184,12 @@ module.exports = [
     finalUrl: BASE_URL + 'seo-tap-targets.html',
     audits: {
       'tap-targets': {
-        score: 0.8, // 10 passing targets/11 total visible targets, multiplied by 0.89 to get the score
+        score: (() => {
+          const PASSING_TAP_TARGETS = 11;
+          const TOTAL_TAP_TARGETS = 12;
+          const SCORE_FACTOR = 0.89;
+          return Math.floor(PASSING_TAP_TARGETS / TOTAL_TAP_TARGETS * SCORE_FACTOR * 100) / 100;
+        })(),
         details: {
           items: [
             {
@@ -193,7 +198,7 @@ module.exports = [
                 'snippet': '<a ' +
                  'style="display: block; width: 100px; height: 30px;background: #ddd;">' +
                  '\n        too small target\n      </a>',
-                'path': '2,HTML,1,BODY,2,DIV,21,DIV,0,A',
+                'path': '2,HTML,1,BODY,3,DIV,21,DIV,0,A',
                 'selector': 'body > div > div > a',
               },
               'overlappingTarget': {
@@ -201,7 +206,7 @@ module.exports = [
                 'snippet': '<a ' +
                   'style="display: block; width: 100px; height: 100px;background: #aaa;">' +
                   '\n        big enough target\n      </a>',
-                'path': '2,HTML,1,BODY,2,DIV,21,DIV,1,A',
+                'path': '2,HTML,1,BODY,3,DIV,21,DIV,1,A',
                 'selector': 'body > div > div > a',
               },
               'size': '100x30',
diff --git a/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js b/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
index c43793ceb..5e8f8d20f 100644
--- a/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
@@ -26,26 +26,29 @@ describe('Resources are fetched over http/2', () => {
   }
 
   it('fails when some resources were requested via http/1.x', () => {
-    return UsesHTTP2Audit.audit(
-      getArtifacts(networkRecords, URL), {computedCache: new Map()}
-    ).then(auditResult => {
-      assert.equal(auditResult.rawValue, false);
-      assert.ok(auditResult.displayValue.match('3 requests not'));
-      assert.equal(auditResult.details.items.length, 3);
-      assert.equal(auditResult.details.items[0].url, 'https://webtide.com/wp-content/plugins/wp-pagenavi/pagenavi-css.css?ver=2.70');
-      const headers = auditResult.details.headings;
-      assert.equal(headers[0].text, 'URL', 'table headings are correct and in order');
-      assert.equal(headers[1].text, 'Protocol', 'table headings are correct and in order');
-    });
+    return UsesHTTP2Audit.audit(getArtifacts(networkRecords, URL), {computedCache: new Map()}).then(
+      auditResult => {
+        assert.equal(auditResult.rawValue, false);
+        assert.ok(auditResult.displayValue.match('3 requests not'));
+        assert.equal(auditResult.details.items.length, 3);
+        assert.equal(
+          auditResult.details.items[0].url,
+          'https://webtide.com/wp-content/plugins/wp-pagenavi/pagenavi-css.css?ver=2.70'
+        );
+        const headers = auditResult.details.headings;
+        assert.equal(headers[0].text, 'URL', 'table headings are correct and in order');
+        assert.equal(headers[1].text, 'Protocol', 'table headings are correct and in order');
+      }
+    );
   });
 
   it('displayValue is correct when only one resource fails', () => {
     const entryWithHTTP1 = networkRecords.slice(1, 2);
-    return UsesHTTP2Audit.audit(
-      getArtifacts(entryWithHTTP1, URL), {computedCache: new Map()}
-    ).then(auditResult => {
-      assert.ok(auditResult.displayValue.match('1 request not'));
-    });
+    return UsesHTTP2Audit.audit(getArtifacts(entryWithHTTP1, URL), {computedCache: new Map()}).then(
+      auditResult => {
+        assert.ok(auditResult.displayValue.match('1 request not'));
+      }
+    );
   });
 
   it('passes when all resources were requested via http/2', () => {
@@ -54,11 +57,34 @@ describe('Resources are fetched over http/2', () => {
       record.protocol = 'h2';
     });
 
-    return UsesHTTP2Audit.audit(
-      getArtifacts(h2Records, URL), {computedCache: new Map()}
-    ).then(auditResult => {
-      assert.equal(auditResult.rawValue, true);
-      assert.ok(auditResult.displayValue === '');
+    return UsesHTTP2Audit.audit(getArtifacts(h2Records, URL), {computedCache: new Map()}).then(
+      auditResult => {
+        assert.equal(auditResult.rawValue, true);
+        assert.ok(auditResult.displayValue === '');
+      }
+    );
+  });
+
+  it('results are correct when some requests are handled by service worker', () => {
+    const clonedNetworkRecords = JSON.parse(JSON.stringify(networkRecords));
+    clonedNetworkRecords.forEach(record => {
+      // convert http 1.1 to service worker requests
+      if (record.protocol === 'http/1.1') {
+        record.fetchedViaServiceWorker = true;
+      }
+    });
+
+    return UsesHTTP2Audit.audit(getArtifacts(clonedNetworkRecords, URL), {
+      computedCache: new Map(),
+    }).then(auditResult => {
+      assert.equal(auditResult.rawValue, false);
+      assert.ok(auditResult.displayValue.match('1 request not'));
+      // Protocol is http/1.0 which we don't mark as fetched fetchedViaServiceWorker on line 73.
+      assert.equal(
+        auditResult.details.items[0].url,
+        'https://webtide.com/wp-content/themes/clean-retina-pro/library/js/tinynav.js?ver=4.5.4'
+      );
+      assert.equal(auditResult.details.items[0].protocol, 'http/1.0');
     });
   });
 });
diff --git a/lighthouse-core/test/audits/image-aspect-ratio-test.js b/lighthouse-core/test/audits/image-aspect-ratio-test.js
index 6b3902db4..8717a371f 100644
--- a/lighthouse-core/test/audits/image-aspect-ratio-test.js
+++ b/lighthouse-core/test/audits/image-aspect-ratio-test.js
@@ -39,6 +39,15 @@ describe('Images: aspect-ratio audit', () => {
     });
   }
 
+  testImage('is a css image', {
+    rawValue: true,
+    clientSize: [1000, 20],
+    naturalSize: [5, 5],
+    props: {
+      isCss: true,
+    },
+  });
+
   testImage('is much larger than natural aspect ratio', {
     rawValue: false,
     clientSize: [800, 500],
diff --git a/lighthouse-core/test/audits/seo/hreflang-test.js b/lighthouse-core/test/audits/seo/hreflang-test.js
index d9c72aa39..60e20523b 100644
--- a/lighthouse-core/test/audits/seo/hreflang-test.js
+++ b/lighthouse-core/test/audits/seo/hreflang-test.js
@@ -5,185 +5,86 @@
  */
 'use strict';
 
-const HreflangAudit = require('../../../audits/seo/hreflang.js');
 const assert = require('assert');
-const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
+const HreflangAudit = require('../../../audits/seo/hreflang.js');
 
 /* eslint-env jest */
 
 describe('SEO: Document has valid hreflang code', () => {
   it('fails when language code provided in hreflang via link element is invalid', () => {
-    const hreflangValues = [
-      'xx',
-      'XX-be',
-      'XX-be-Hans',
-      '',
-      '  es',
-    ];
-
-    const allRuns = hreflangValues.map(hreflangValue => {
-      const finalUrl = 'https://example.com';
-      const mainResource = {
-        url: finalUrl,
-        responseHeaders: [],
-      };
-      const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
-      const artifacts = {
-        devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-        URL: {finalUrl},
-        Hreflang: [{
-          hreflang: hreflangValue,
-          href: 'https://example.com',
-        }],
-      };
-
-      const context = {computedCache: new Map()};
-      return HreflangAudit.audit(artifacts, context).then(auditResult => {
-        assert.equal(auditResult.rawValue, false);
-        assert.equal(auditResult.details.items.length, 1);
-      });
-    });
-
-    return Promise.all(allRuns);
-  });
-
-  it('succeeds when language code provided via link element is valid', () => {
-    const finalUrl = 'https://example.com';
-    const mainResource = {
-      url: finalUrl,
-      responseHeaders: [],
-    };
-    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
     const artifacts = {
-      devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-      URL: {finalUrl},
-      Hreflang: [
-        {hreflang: 'pl'},
-        {hreflang: 'nl-be'},
-        {hreflang: 'zh-Hans'},
-        {hreflang: 'x-default'},
-        {hreflang: 'FR-BE'},
+      LinkElements: [
+        {rel: 'alternate', hreflang: 'xx1', href: 'http://example.com/', source: 'headers'},
+        {rel: 'alternate', hreflang: 'XX-be', href: 'http://example.com/', source: 'headers'},
+        {rel: 'alternate', hreflang: 'XX-be-Hans', href: 'http://example.com/', source: 'head'},
+        {rel: 'alternate', hreflang: '  es', href: 'http://example.com/', source: 'head'},
+        {rel: 'alternate', hreflang: '  es', href: 'http://example.com/', source: 'headers'},
       ],
     };
 
-    const context = {computedCache: new Map()};
-    return HreflangAudit.audit(artifacts, context).then(auditResult => {
-      assert.equal(auditResult.rawValue, true);
-    });
+    const auditResult = HreflangAudit.audit(artifacts);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.details.items.length, 5);
   });
 
-  it('succeeds when there are no rel=alternate link elements nor headers', () => {
-    const finalUrl = 'https://example.com';
-    const mainResource = {
-      url: finalUrl,
-      responseHeaders: [],
-    };
-    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
-    const artifacts = {
-      devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-      URL: {finalUrl},
-      Hreflang: [],
-    };
+  it('succeeds when language code provided in hreflang via body is invalid', () => {
+    const hreflangValues = ['xx', 'XX-be', 'XX-be-Hans', '', '  es'];
+
+    for (const hreflangValue of hreflangValues) {
+      const artifacts = {
+        LinkElements: [
+          {
+            source: 'body',
+            rel: 'alternate',
+            hreflang: hreflangValue,
+            href: 'https://example.com',
+          },
+        ],
+      };
 
-    const context = {computedCache: new Map()};
-    return HreflangAudit.audit(artifacts, context).then(auditResult => {
+      const auditResult = HreflangAudit.audit(artifacts);
       assert.equal(auditResult.rawValue, true);
-    });
+    }
   });
 
-  it('fails when language code provided in hreflang via header is invalid', () => {
-    const linkHeaders = [
-      [
-        {name: 'Link', value: '<http://es.example.com/>; rel="alternate"; hreflang="xx"'},
-      ],
-      [
-        {name: 'link', value: '<http://es.example.com/>; rel="alternate"; hreflang=""'},
-      ],
-      [
-        {name: 'LINK', value: '<http://es.example.com/>; rel="alternate"'},
-      ],
-      [
-        {name: 'Link', value: '<http://es.example.com/>; rel="alternate"; hreflang="es",<http://xx.example.com/>; rel="alternate"; Hreflang="xx"'},
-      ],
-      [
-        {name: 'link', value: '<http://es.example.com/>; rel="alternate"; hreflang="es"'},
-        {name: 'Link', value: '<http://xx.example.com/>; rel="alternate"; hreflang="x"'},
-      ],
-    ];
+  it('succeeds when language code provided via head/headers is valid', () => {
+    const hreflangValues = ['pl', 'nl-be', 'zh-Hans', 'x-default', 'FR-BE'];
 
-    const allRuns = linkHeaders.map(headers => {
-      const finalUrl = 'https://example.com';
-      const mainResource = {
-        url: finalUrl,
-        responseHeaders: headers,
-      };
-      const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
+    let inHead = false;
+    for (const hreflangValue of hreflangValues) {
       const artifacts = {
-        devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-        URL: {finalUrl},
-        Hreflang: null,
+        LinkElements: [
+          {
+            source: inHead ? 'head' : 'headers',
+            rel: 'alternate',
+            hreflang: hreflangValue,
+            href: 'https://example.com',
+          },
+        ],
       };
 
-      const context = {computedCache: new Map()};
-      return HreflangAudit.audit(artifacts, context).then(auditResult => {
-        assert.equal(auditResult.rawValue, false);
-        assert.equal(auditResult.details.items.length, 1);
-      });
-    });
-
-    return Promise.all(allRuns);
+      const auditResult = HreflangAudit.audit(artifacts);
+      assert.equal(auditResult.rawValue, true);
+      inHead = !inHead;
+    }
   });
 
-  it('succeeds when language codes provided via Link header are valid', () => {
-    const finalUrl = 'https://example.com';
-    const mainResource = {
-      url: finalUrl,
-      responseHeaders: [
-        {name: 'link', value: ''},
-        {name: 'link', value: 'garbage'},
-        {name: 'link', value: '<http://es.example.com/>; rel="example"; hreflang="xx"'},
-        {name: 'link', value: '<http://es.example.com/>; rel="alternate"; hreflang="es"'},
-        {name: 'Link', value: '<http://fr.example.com/>; rel="alternate"; hreflang="fr-be"'},
-        {name: 'LINK', value: '<http://es.example.com/>; rel="alternate"; hreflang="es",<http://fr.example.com/>; rel="alternate"; Hreflang="fr-be"'},
-      ],
-    };
-    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
-    const artifacts = {
-      devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-      URL: {finalUrl},
-      Hreflang: null,
-    };
-
-    const context = {computedCache: new Map()};
-    return HreflangAudit.audit(artifacts, context).then(auditResult => {
-      assert.equal(auditResult.rawValue, true);
-    });
+  it('succeeds when there are no rel=alternate link elements nor headers', () => {
+    assert.equal(HreflangAudit.audit({LinkElements: []}).rawValue, true);
   });
 
   it('returns all failing items', () => {
-    const finalUrl = 'https://example.com';
-    const mainResource = {
-      url: finalUrl,
-      responseHeaders: [
-        {name: 'link', value: '<http://xx1.example.com/>; rel="alternate"; hreflang="xx1"'},
-        {name: 'Link', value: '<http://xx2.example.com/>; rel="alternate"; hreflang="xx2"'},
-      ],
-    };
-    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
     const artifacts = {
-      devtoolsLogs: {[HreflangAudit.DEFAULT_PASS]: devtoolsLog},
-      URL: {finalUrl},
-      Hreflang: [{
-        hreflang: 'xx3',
-      }, {
-        hreflang: 'xx4',
-      }],
+      LinkElements: [
+        {rel: 'alternate', hreflang: 'xx1', href: 'http://xx1.example.com/', source: 'headers'},
+        {rel: 'alternate', hreflang: 'xx2', href: 'http://xx2.example.com/', source: 'headers'},
+        {rel: 'alternate', hreflang: 'xx3', href: 'http://xx3.example.com/', source: 'head'},
+        {rel: 'alternate', hreflang: 'xx4', href: 'http://xx4.example.com/', source: 'head'},
+      ],
     };
 
-    const context = {computedCache: new Map()};
-    return HreflangAudit.audit(artifacts, context).then(auditResult => {
-      assert.equal(auditResult.rawValue, false);
-      assert.equal(auditResult.details.items.length, 4);
-    });
+    const auditResult = HreflangAudit.audit(artifacts);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.details.items.length, 4);
   });
 });
diff --git a/lighthouse-core/test/fixtures/lr.devtoolslog.json b/lighthouse-core/test/fixtures/lr.devtoolslog.json
new file mode 100644
index 000000000..3db6a6cc7
--- /dev/null
+++ b/lighthouse-core/test/fixtures/lr.devtoolslog.json
@@ -0,0 +1,231 @@
+[
+    {
+        "method": "Network.requestWillBeSent",
+        "params": {
+            "requestId": "B38A026891F997E88601C20D85DC4616",
+            "loaderId": "B38A026891F997E88601C20D85DC4616",
+            "documentURL": "https://www.paulirish.com/",
+            "request": {
+                "url": "https://www.paulirish.com/",
+                "method": "GET",
+                "headers": {
+                    "Upgrade-Insecure-Requests": "1",
+                    "User-Agent": "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",
+                    "Accept-Language": "en-US"
+                },
+                "mixedContentType": "none",
+                "initialPriority": "VeryHigh",
+                "referrerPolicy": "no-referrer-when-downgrade"
+            },
+            "timestamp": 10696044.979453,
+            "wallTime": 1552333431.031513,
+            "initiator": {
+                "type": "other"
+            },
+            "type": "Document",
+            "frameId": "31670FA9D804DF7165CB1197095646F9",
+            "hasUserGesture": false
+        }
+    },
+    {
+        "method": "Network.responseReceived",
+        "params": {
+            "requestId": "B38A026891F997E88601C20D85DC4616",
+            "loaderId": "B38A026891F997E88601C20D85DC4616",
+            "timestamp": 10696045.169156,
+            "type": "Document",
+            "response": {
+                "url": "https://www.paulirish.com/",
+                "status": 200,
+                "statusText": "OK",
+                "headers": {
+                    "Date": "Mon, 11 Mar 2019 19:43:51 GMT",
+                    "Content-Type": "text/html",
+                    "Transfer-Encoding": "chunked",
+                    "Connection": "keep-alive",
+                    "Set-Cookie": "__cfduid=d3c8d1a78f953298e1297c905193115fd1552333431; path=/; domain=.paulirish.com; HttpOnly",
+                    "Last-Modified": "Wed, 28 Feb 2018 20:45:39 GMT",
+                    "Vary": "Accept-Encoding",
+                    "Content-Security-Policy": "default-src 'self' ; script-src 'self' 'unsafe-inline' https://ssl.google-analytics.com https://api.github.com https://disqus.com https://go.disqus.com https://*.disquscdn.com https://www.google-analytics.com https://paulirish.disqus.com https://platform.twitter.com https://cdn.syndication.twimg.com https://ajax.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://platform.twitter.com https://c.disquscdn.com; img-src * 'self' data: ; font-src 'self' data: https://fonts.gstatic.com; object-src http://vimeo.com http://static.slidesharecdn.com; frame-src 'self' https://platform.twitter.com https://accounts.google.com https://player.vimeo.com https://www.youtube.com https://apis.google.com https://disqus.com https://www.youtube.com https://paulirish.wufoo.com http://jsfiddle.net http://player.vimeo.com https://staticxx.facebook.com https://syndication.twitter.com; worker-src 'self' blob:; upgrade-insecure-requests; report-uri https://paulirish.report-uri.com/r/d/csp/enforce;",
+                    "Expect-CT": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"",
+                    "Server": "cloudflare",
+                    "CF-RAY": "4b6005086d532aa3-SEA",
+                    "X-Original-Content-Encoding": "br",
+                    "X-TotalFetchedSize": "22889"
+                },
+                "mimeType": "text/html",
+                "connectionReused": false,
+                "connectionId": 0,
+                "remoteIPAddress": "",
+                "remotePort": 0,
+                "fromDiskCache": false,
+                "fromServiceWorker": false,
+                "encodedDataLength": 1549,
+                "timing": {
+                    "requestTime": 10696044.979916,
+                    "proxyStart": -1,
+                    "proxyEnd": -1,
+                    "dnsStart": -1,
+                    "dnsEnd": -1,
+                    "connectStart": -1,
+                    "connectEnd": -1,
+                    "sslStart": -1,
+                    "sslEnd": -1,
+                    "workerStart": -1,
+                    "workerReady": -1,
+                    "sendStart": -1,
+                    "sendEnd": -1,
+                    "pushStart": 0,
+                    "pushEnd": 0,
+                    "receiveHeadersEnd": 188.235
+                },
+                "protocol": "http/1.1",
+                "securityState": "secure"
+            },
+            "frameId": "31670FA9D804DF7165CB1197095646F9"
+        }
+    },
+    {
+        "method": "Network.dataReceived",
+        "params": {
+            "requestId": "B38A026891F997E88601C20D85DC4616",
+            "timestamp": 10696045.172754,
+            "dataLength": 65536,
+            "encodedDataLength": 0
+        }
+    },
+    {
+        "method": "Network.dataReceived",
+        "params": {
+            "requestId": "B38A026891F997E88601C20D85DC4616",
+            "timestamp": 10696045.182541,
+            "dataLength": 9685,
+            "encodedDataLength": 75221
+        }
+    },
+    {
+        "method": "Network.loadingFinished",
+        "params": {
+            "requestId": "B38A026891F997E88601C20D85DC4616",
+            "timestamp": 10696045.168171,
+            "encodedDataLength": 76770,
+            "shouldReportCorbBlocking": false
+        }
+    },
+    {
+        "method": "Network.requestWillBeSent",
+        "params": {
+            "requestId": "1000170085.13",
+            "loaderId": "B38A026891F997E88601C20D85DC4616",
+            "documentURL": "https://www.paulirish.com/",
+            "request": {
+                "url": "https://www.paulirish.com/javascripts/modernizr-2.0.js",
+                "method": "GET",
+                "headers": {
+                    "Referer": "https://www.paulirish.com/",
+                    "User-Agent": "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"
+                },
+                "mixedContentType": "none",
+                "initialPriority": "Medium",
+                "referrerPolicy": "no-referrer-when-downgrade"
+            },
+            "timestamp": 10696045.190184,
+            "wallTime": 1552333431.24224,
+            "initiator": {
+                "type": "parser",
+                "url": "https://www.paulirish.com/",
+                "lineNumber": 444
+            },
+            "type": "Script",
+            "frameId": "31670FA9D804DF7165CB1197095646F9",
+            "hasUserGesture": false
+        }
+    },
+    {
+        "method": "Network.responseReceived",
+        "params": {
+            "requestId": "1000170085.13",
+            "loaderId": "B38A026891F997E88601C20D85DC4616",
+            "timestamp": 10696045.906526,
+            "type": "Script",
+            "response": {
+                "url": "https://www.paulirish.com/javascripts/modernizr-2.0.js",
+                "status": 200,
+                "statusText": "OK",
+                "headers": {
+                    "Date": "Mon, 11 Mar 2019 19:43:51 GMT",
+                    "CF-Cache-Status": "HIT",
+                    "Cf-Polished": "origSize=9946",
+                    "Transfer-Encoding": "chunked",
+                    "X-TotalFetchedSize": "4730",
+                    "Connection": "keep-alive",
+                    "Last-Modified": "Wed, 28 Feb 2018 20:45:39 GMT",
+                    "Server": "cloudflare",
+                    "ETag": "W/\"26da-5664bd3750cc6\"",
+                    "Expect-CT": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"",
+                    "Vary": "Accept-Encoding",
+                    "Content-Type": "application/javascript",
+                    "Cf-Bgj": "minify",
+                    "Cache-Control": "public, max-age=86400",
+                    "X-Original-Content-Encoding": "br",
+                    "CF-RAY": "4b6005098dce2aa3-SEA",
+                    "Expires": "Tue, 12 Mar 2019 19:43:51 GMT"
+                },
+                "mimeType": "application/javascript",
+                "connectionReused": false,
+                "connectionId": 0,
+                "fromDiskCache": false,
+                "fromServiceWorker": false,
+                "encodedDataLength": 579,
+                "timing": {
+                    "requestTime": 10696045.190569,
+                    "proxyStart": -1,
+                    "proxyEnd": -1,
+                    "dnsStart": -1,
+                    "dnsEnd": -1,
+                    "connectStart": -1,
+                    "connectEnd": -1,
+                    "sslStart": -1,
+                    "sslEnd": -1,
+                    "workerStart": -1,
+                    "workerReady": -1,
+                    "sendStart": -1,
+                    "sendEnd": -1,
+                    "pushStart": 0,
+                    "pushEnd": 0,
+                    "receiveHeadersEnd": 148.784
+                },
+                "protocol": "http/1.1",
+                "securityState": "unknown"
+            },
+            "frameId": "31670FA9D804DF7165CB1197095646F9"
+        }
+    },
+    {
+        "method": "Network.dataReceived",
+        "params": {
+            "requestId": "1000170085.13",
+            "timestamp": 10696045.906583,
+            "dataLength": 9736,
+            "encodedDataLength": 0
+        }
+    },
+    {
+        "method": "Network.dataReceived",
+        "params": {
+            "requestId": "1000170085.13",
+            "timestamp": 10696045.90672,
+            "dataLength": 0,
+            "encodedDataLength": 9736
+        }
+    },
+    {
+        "method": "Network.loadingFinished",
+        "params": {
+            "requestId": "1000170085.13",
+            "timestamp": 10696045.339356,
+            "encodedDataLength": 10315,
+            "shouldReportCorbBlocking": false
+        }
+    }
+]
\ No newline at end of file
diff --git a/lighthouse-core/test/lib/network-recorder-test.js b/lighthouse-core/test/lib/network-recorder-test.js
index 3f36da568..b3f724c96 100644
--- a/lighthouse-core/test/lib/network-recorder-test.js
+++ b/lighthouse-core/test/lib/network-recorder-test.js
@@ -11,6 +11,7 @@ const assert = require('assert');
 const devtoolsLogItems = require('../fixtures/artifacts/perflog/defaultPass.devtoolslog.json');
 const redirectsDevtoolsLog = require('../fixtures/wikipedia-redirect.devtoolslog.json');
 const redirectsScriptDevtoolsLog = require('../fixtures/redirects-from-script.devtoolslog.json');
+const lrRequestDevtoolsLog = require('../fixtures/lr.devtoolslog.json');
 
 /* eslint-env jest */
 describe('network recorder', function() {
@@ -162,6 +163,23 @@ describe('network recorder', function() {
     expect(records).toMatchObject([{url: 'http://example.com', startTime: 1, endTime: 2}]);
   });
 
+  it('should use X-TotalFetchedSize in Lightrider for transferSize', () => {
+    global.isLightrider = true;
+    const records = NetworkRecorder.recordsFromLogs(lrRequestDevtoolsLog);
+    global.isLightrider = false;
+
+    expect(records.find(r => r.url === 'https://www.paulirish.com/'))
+    .toMatchObject({
+      resourceSize: 75221,
+      transferSize: 22889,
+    });
+    expect(records.find(r => r.url === 'https://www.paulirish.com/javascripts/modernizr-2.0.js'))
+    .toMatchObject({
+      resourceSize: 9736,
+      transferSize: 4730,
+    });
+  });
+
   describe('#findNetworkQuietPeriods', () => {
     function record(data) {
       const url = data.url || 'https://example.com';
diff --git a/lighthouse-core/test/network-records-to-devtools-log.js b/lighthouse-core/test/network-records-to-devtools-log.js
index 0eb6e9e1c..50c380345 100644
--- a/lighthouse-core/test/network-records-to-devtools-log.js
+++ b/lighthouse-core/test/network-records-to-devtools-log.js
@@ -100,7 +100,7 @@ function getResponseReceivedEvent(networkRecord, index) {
         connectionReused: networkRecord.connectionReused || false,
         connectionId: networkRecord.connectionId || 140,
         fromDiskCache: networkRecord.fromDiskCache || undefined,
-        fromServiceWorker: networkRecord.fromServiceWorker || undefined,
+        fromServiceWorker: networkRecord.fetchedViaServiceWorker || undefined,
         encodedDataLength: networkRecord.transferSize || 0,
         timing,
         protocol: networkRecord.protocol || 'http/1.1',
diff --git a/lighthouse-core/test/report/html/renderer/dom-test.js b/lighthouse-core/test/report/html/renderer/dom-test.js
index 96bda5074..ea91f810f 100644
--- a/lighthouse-core/test/report/html/renderer/dom-test.js
+++ b/lighthouse-core/test/report/html/renderer/dom-test.js
@@ -23,6 +23,7 @@ describe('DOM', () => {
     global.URL = URL;
     const {document} = new jsdom.JSDOM(TEMPLATE_FILE).window;
     dom = new DOM(document);
+    dom.setLighthouseChannel('someChannel');
   });
 
   afterAll(() => {
@@ -115,6 +116,20 @@ describe('DOM', () => {
       assert.equal(result.innerHTML, 'Ensuring `&lt;td&gt;` cells using the `[headers]` are ' +
           'good. <a rel="noopener" target="_blank" href="https://dequeuniversity.com/rules/axe/3.1/td-headers-attr">Learn more</a>.');
     });
+
+    it('appends utm params to the URLs with https://developers.google.com origin', () => {
+      const text = '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/description).';
+
+      const result = dom.convertMarkdownLinkSnippets(text);
+      assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://developers.google.com/web/tools/lighthouse/audits/description?utm_source=lighthouse&amp;utm_medium=someChannel">Learn more</a>.');
+    });
+
+    it('doesn\'t append utm params to non https://developers.google.com origins', () => {
+      const text = '[Learn more](https://example.com/info).';
+
+      const result = dom.convertMarkdownLinkSnippets(text);
+      assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://example.com/info">Learn more</a>.');
+    });
   });
 
   describe('convertMarkdownCodeSnippets', () => {
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 478d8dd81..f8362c66c 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -199,6 +199,25 @@ describe('ReportRenderer', () => {
     assert.ok(output.querySelector('.score100'), 'has fireworks treatment');
   });
 
+  it('should add LHR channel to doc link parameters', () => {
+    const lhrChannel = sampleResults.configSettings.channel;
+    // Make sure we have a channel in the LHR.
+    assert.ok(lhrChannel.length > 2);
+
+    const container = renderer._dom._document.body;
+    const output = renderer.renderReport(sampleResults, container);
+
+    const utmChannels = [...output.querySelectorAll('a[href*="utm_source=lighthouse"')]
+      .map(a => new URL(a.href))
+      .filter(url => url.origin === 'https://developers.google.com')
+      .map(url => url.searchParams.get('utm_medium'));
+
+    assert.ok(utmChannels.length > 20);
+    utmChannels.forEach(anchorChannel => {
+      assert.strictEqual(anchorChannel, lhrChannel);
+    });
+  });
+
   it('renders `not_applicable` audits as `notApplicable`', () => {
     const clonedSampleResult = JSON.parse(JSON.stringify(sampleResultsOrig));