Repository: GoogleChrome/lighthouse
Pull request #6972 (2019-01-09 22:13:46)

report(css): use padding for PWA vertical border space
<!-- Thank you for submitting a pull request! -->

**Summary**
The vertical separator looks squished when looking at small width screens.  On large width screens this makes a not very noticable 1px difference :man_shrugging: 

Don't know if this is WAI, but I wanted to mention it since I thought it looked a bit off.

Current view on min-width screens:
![margin](https://user-images.githubusercontent.com/6392995/50931748-618e5800-1418-11e9-896f-7bc7a3fa08e8.png)
With padding instead of margin:
![padding](https://user-images.githubusercontent.com/6392995/50931744-5f2bfe00-1418-11e9-834f-51d515c0e38e.png)



Patch:
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index d49741747..9676a8f4a 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -26,6 +26,8 @@ const NON_BUG_ERROR_MESSAGES = {
       'are trying to review.',
   'Cannot access contents of the page': 'Lighthouse can only audit URLs that start' +
       ' with http:// or https://.',
+  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have valid' +
+      ' security credentials.',
   'INVALID_URL': 'Lighthouse can only audit URLs that start' +
       ' with http:// or https://.',
 };
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index c6ca5dd57..d366eb918 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -35,7 +35,7 @@ function getFlags(manualArgv) {
           'lighthouse <url> --disable-device-emulation --throttling-method=provided',
           'Disable device emulation and all throttling')
       .example(
-          'lighthouse <url> --chrome-flags="--window-size=412,732"',
+          'lighthouse <url> --chrome-flags="--window-size=412,660"',
           'Launch Chrome with a specific window size')
       .example(
           'lighthouse <url> --quiet --chrome-flags="--headless"',
@@ -137,7 +137,6 @@ function getFlags(manualArgv) {
       // default values
       .default('chrome-flags', '')
       .default('output', ['html'])
-      .default('emulated-form-factor', 'mobile')
       .default('port', 0)
       .default('hostname', 'localhost')
       .default('enable-error-reporting', undefined) // Undefined so prompted by default
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index a602edc7b..28081d5e3 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -23,6 +23,7 @@ const opn = require('opn');
 const _RUNTIME_ERROR_CODE = 1;
 const _PROTOCOL_TIMEOUT_EXIT_CODE = 67;
 const _PAGE_HUNG_EXIT_CODE = 68;
+const _INSECURE_DOCUMENT_REQUEST_EXIT_CODE = 69;
 
 /**
  * exported for testing
@@ -77,6 +78,12 @@ function showPageHungError(err) {
   process.exit(_PAGE_HUNG_EXIT_CODE);
 }
 
+/** @param {LH.LighthouseError} err */
+function showInsecureDocumentRequestError(err) {
+  console.error('Insecure document request:', err.friendlyMessage);
+  process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
+}
+
 /**
  * @param {LH.LighthouseError} err
  */
@@ -98,6 +105,8 @@ function handleError(err) {
     showProtocolTimeoutError();
   } else if (err.code === 'PAGE_HUNG') {
     showPageHungError(err);
+  } else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
+    showInsecureDocumentRequestError(err);
   } else {
     showRuntimeError(err);
   }
diff --git a/lighthouse-core/config/lr-desktop-config.js b/lighthouse-core/config/lr-desktop-config.js
index 36ba73592..11fc64485 100644
--- a/lighthouse-core/config/lr-desktop-config.js
+++ b/lighthouse-core/config/lr-desktop-config.js
@@ -21,6 +21,19 @@ const config = {
     // Skip the h2 audit so it doesn't lie to us. See #6539
     skipAudits: ['uses-http2'],
   },
+  audits: [
+    // 75th and 95th percentiles -> median and PODR
+    // SELECT QUANTILES(renderStart, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
+    {path: 'metrics/first-contentful-paint', options: {scorePODR: 800, scoreMedian: 1600}},
+    {path: 'metrics/first-meaningful-paint', options: {scorePODR: 800, scoreMedian: 1600}},
+    // 75th and 95th percentiles -> median and PODR
+    // SELECT QUANTILES(SpeedIndex, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
+    {path: 'metrics/speed-index', options: {scorePODR: 1100, scoreMedian: 2300}},
+    // 75th and 95th percentiles -> median and PODR
+    // SELECT QUANTILES(fullyLoaded, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
+    {path: 'metrics/interactive', options: {scorePODR: 2000, scoreMedian: 4500}},
+    {path: 'metrics/first-cpu-idle', options: {scorePODR: 2000, scoreMedian: 4500}},
+  ],
 };
 
 module.exports = config;
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 27674132f..a831effb9 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -81,13 +81,6 @@ class Driver {
       this._eventEmitter.emit(event.method, event.params);
     });
 
-    /**
-     * Used for monitoring network status events during gotoURL.
-     * @type {?LH.Crdp.Security.SecurityStateChangedEvent}
-     * @private
-     */
-    this._lastSecurityState = null;
-
     /**
      * @type {number}
      * @private
@@ -546,14 +539,17 @@ class Driver {
       /** @param {LH.Crdp.Page.LifecycleEventEvent} e */
       const lifecycleListener = e => {
         if (e.name === 'firstContentfulPaint') {
+          cancel();
           resolve();
-          this.off('Page.lifecycleEvent', lifecycleListener);
         }
       };
 
       this.on('Page.lifecycleEvent', lifecycleListener);
 
+      let canceled = false;
       cancel = () => {
+        if (canceled) return;
+        canceled = true;
         this.off('Page.lifecycleEvent', lifecycleListener);
       };
     });
@@ -634,7 +630,10 @@ class Driver {
       networkStatusMonitor.on('network-2-busy', logStatus);
 
       this.once('Page.domContentEventFired', domContentLoadedListener);
+      let canceled = false;
       cancel = () => {
+        if (canceled) return;
+        canceled = true;
         idleTimeout && clearTimeout(idleTimeout);
         this.off('Page.domContentEventFired', domContentLoadedListener);
         networkStatusMonitor.removeListener('network-2-busy', onBusy);
@@ -666,7 +665,7 @@ class Driver {
 
     /** @type {NodeJS.Timer|undefined} */
     let lastTimeout;
-    let cancelled = false;
+    let canceled = false;
 
     const checkForQuietExpression = `(${pageFunctions.checkTimeSinceLastLongTaskString})()`;
     /**
@@ -675,9 +674,9 @@ class Driver {
      * @return {Promise<void>}
      */
     async function checkForQuiet(driver, resolve) {
-      if (cancelled) return;
+      if (canceled) return;
       const timeSinceLongTask = await driver.evaluateAsync(checkForQuietExpression);
-      if (cancelled) return;
+      if (canceled) return;
 
       if (typeof timeSinceLongTask === 'number') {
         if (timeSinceLongTask >= waitForCPUQuiet) {
@@ -698,9 +697,10 @@ class Driver {
     const promise = new Promise((resolve, reject) => {
       checkForQuiet(this, resolve).catch(reject);
       cancel = () => {
-        cancelled = true;
+        if (canceled) return;
+        canceled = true;
         if (lastTimeout) clearTimeout(lastTimeout);
-        reject(new Error('Wait for CPU idle cancelled'));
+        reject(new Error('Wait for CPU idle canceled'));
       };
     });
 
@@ -731,7 +731,10 @@ class Driver {
       };
       this.once('Page.loadEventFired', loadListener);
 
+      let canceled = false;
       cancel = () => {
+        if (canceled) return;
+        canceled = true;
         this.off('Page.loadEventFired', loadListener);
         loadTimeout && clearTimeout(loadTimeout);
       };
@@ -743,6 +746,49 @@ class Driver {
     };
   }
 
+  /**
+   * Return a promise that resolves when an insecure security state is encountered
+   * and a method to cancel internal listeners.
+   * @return {{promise: Promise<string>, cancel: function(): void}}
+   * @private
+   */
+  _monitorForInsecureState() {
+    /** @type {(() => void)} */
+    let cancel = () => {
+      throw new Error('_monitorForInsecureState.cancel() called before it was defined');
+    };
+
+    const promise = new Promise((resolve, reject) => {
+      /**
+       * @param {LH.Crdp.Security.SecurityStateChangedEvent} event
+       */
+      const securityStateChangedListener = ({securityState, explanations}) => {
+        if (securityState === 'insecure') {
+          cancel();
+          const insecureDescriptions = explanations
+            .filter(exp => exp.securityState === 'insecure')
+            .map(exp => exp.description);
+          resolve(insecureDescriptions.join(' '));
+        }
+      };
+      let canceled = false;
+      cancel = () => {
+        if (canceled) return;
+        canceled = true;
+        this.off('Security.securityStateChanged', securityStateChangedListener);
+        // TODO(@patrickhulce): cancel() should really be a promise itself to handle things like this
+        this.sendCommand('Security.disable').catch(() => {});
+      };
+      this.on('Security.securityStateChanged', securityStateChangedListener);
+      this.sendCommand('Security.enable').catch(() => {});
+    });
+
+    return {
+      promise,
+      cancel,
+    };
+  }
+
   /**
    * Returns whether the page appears to be hung.
    * @return {Promise<boolean>}
@@ -765,6 +811,7 @@ class Driver {
   /**
    * Returns a promise that resolves when:
    * - All of the following conditions have been met:
+   *    - page has no security issues
    *    - pauseAfterLoadMs milliseconds have passed since the load event.
    *    - networkQuietThresholdMs milliseconds have passed since the last network request that exceeded
    *      2 inflight requests (network-2-quiet has been reached).
@@ -793,6 +840,13 @@ class Driver {
     // CPU listener. Resolves when the CPU has been idle for cpuQuietThresholdMs after network idle.
     let waitForCPUIdle = this._waitForNothing();
 
+    const monitorForInsecureState = this._monitorForInsecureState();
+    const securityCheckPromise = monitorForInsecureState.promise.then(securityMessages => {
+      return function() {
+        throw new LHError(LHError.errors.INSECURE_DOCUMENT_REQUEST, {securityMessages});
+      };
+    });
+
     // Wait for both load promises. Resolves on cleanup function the clears load
     // timeout timer.
     const loadPromise = Promise.all([
@@ -805,7 +859,6 @@ class Driver {
     }).then(() => {
       return function() {
         log.verbose('Driver', 'loadEventFired and network considered idle');
-        maxTimeoutHandle && clearTimeout(maxTimeoutHandle);
       };
     });
 
@@ -816,11 +869,6 @@ class Driver {
     }).then(_ => {
       return async () => {
         log.warn('Driver', 'Timed out waiting for page load. Checking if page is hung...');
-        waitForFCP.cancel();
-        waitForLoadEvent.cancel();
-        waitForNetworkIdle.cancel();
-        waitForCPUIdle.cancel();
-
         if (await this.isPageHung()) {
           log.warn('Driver', 'Page appears to be hung, killing JavaScript...');
           await this.sendCommand('Emulation.setScriptExecutionDisabled', {value: true});
@@ -830,11 +878,20 @@ class Driver {
       };
     });
 
-    // Wait for load or timeout and run the cleanup function the winner returns.
+    // Wait for security issue, load or timeout and run the cleanup function the winner returns.
     const cleanupFn = await Promise.race([
+      securityCheckPromise,
       loadPromise,
       maxTimeoutPromise,
     ]);
+
+    maxTimeoutHandle && clearTimeout(maxTimeoutHandle);
+    waitForFCP.cancel();
+    waitForLoadEvent.cancel();
+    waitForNetworkIdle.cancel();
+    waitForCPUIdle.cancel();
+    monitorForInsecureState.cancel();
+
     await cleanupFn();
   }
 
@@ -1007,26 +1064,6 @@ class Driver {
     return result.body;
   }
 
-  async listenForSecurityStateChanges() {
-    this.on('Security.securityStateChanged', state => {
-      this._lastSecurityState = state;
-    });
-    await this.sendCommand('Security.enable');
-  }
-
-  /**
-   * @return {LH.Crdp.Security.SecurityStateChangedEvent}
-   */
-  getSecurityState() {
-    if (!this._lastSecurityState) {
-      // happens if 'listenForSecurityStateChanges' is not called,
-      // or if some assumptions about the Security domain are wrong
-      throw new Error('Expected a security state.');
-    }
-
-    return this._lastSecurityState;
-  }
-
   /**
    * @param {string} name The name of API whose permission you wish to query
    * @return {Promise<string>} The state of permissions, resolved in a promise.
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 6c02edb44..53a61a91c 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -110,7 +110,6 @@ class GatherRunner {
     await driver.cacheNatives();
     await driver.registerPerformanceObserver();
     await driver.dismissJavaScriptDialogs();
-    await driver.listenForSecurityStateChanges();
     if (resetStorage) await driver.clearDataForOrigin(options.requestedUrl);
     log.timeEnd(status);
   }
@@ -173,23 +172,6 @@ class GatherRunner {
     }
   }
 
-  /**
-   * Throws an error if the security state is insecure.
-   * @param {LH.Crdp.Security.SecurityStateChangedEvent} securityState
-   * @throws {LHError}
-   */
-  static assertNoSecurityIssues({securityState, explanations}) {
-    if (securityState === 'insecure') {
-      const insecureDescriptions = explanations
-        .filter(exp => exp.securityState === 'insecure')
-        .map(exp => exp.description);
-      throw new LHError(
-        LHError.errors.INSECURE_DOCUMENT_REQUEST,
-        {securityMessages: insecureDescriptions.join(' ')}
-      );
-    }
-  }
-
   /**
    * Calls beforePass() on gatherers before tracing
    * has started and before navigation to the target page.
@@ -312,8 +294,6 @@ class GatherRunner {
     const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
     log.timeEnd(status);
 
-    this.assertNoSecurityIssues(driver.getSecurityState());
-
     let pageLoadError = GatherRunner.getPageLoadError(passContext.url, networkRecords);
     // If the driver was offline, a page load error is expected, so do not save it.
     if (!driver.online) pageLoadError = undefined;
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index b27406c6e..73a443747 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -14,9 +14,9 @@
 const NEXUS5X_EMULATION_METRICS = {
   mobile: true,
   screenWidth: 412,
-  screenHeight: 732,
+  screenHeight: 660,
   width: 412,
-  height: 732,
+  height: 660,
   positionX: 0,
   positionY: 0,
   scale: 1,
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index b5bf42809..3183629ed 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -992,8 +992,8 @@
     "description": "Error message explaining that Lighthouse couldn't complete because the page has stopped responding to its instructions."
   },
   "lighthouse-core/lib/lh-error.js | pageLoadFailedInsecure": {
-    "message": "The URL you have provided does not have valid security credentials. ({securityMessages})",
-    "description": "Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed."
+    "message": "The URL you have provided does not have valid security credentials. {securityMessages}",
+    "description": "Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load."
   },
   "lighthouse-core/lib/lh-error.js | pageLoadFailedWithDetails": {
     "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})",
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index df1b3f406..d1ef25f2c 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -21,8 +21,8 @@ const UIStrings = {
   pageLoadFailedWithStatusCode: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: {statusCode})',
   /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
   pageLoadFailedWithDetails: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})',
-  /** Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. */
-  pageLoadFailedInsecure: 'The URL you have provided does not have valid security credentials. ({securityMessages})',
+  /** Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load. */
+  pageLoadFailedInsecure: 'The URL you have provided does not have valid security credentials. {securityMessages}',
   /** Error message explaining that Chrome has encountered an error during the Lighthouse run, and that Chrome should be restarted. */
   internalChromeError: 'An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.',
   /** Error message explaining that fetching the resources of the webpage has taken longer than the maximum time. */
diff --git a/lighthouse-core/lib/traces/pwmetrics-events.js b/lighthouse-core/lib/traces/pwmetrics-events.js
index 3fc0aa9fa..33fccecc6 100644
--- a/lighthouse-core/lib/traces/pwmetrics-events.js
+++ b/lighthouse-core/lib/traces/pwmetrics-events.js
@@ -3,24 +3,28 @@
  * 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.
  */
-// @ts-nocheck
 'use strict';
 
 const log = require('lighthouse-logger');
 
-// TODO: rework this file to not need this function
-// see https://github.com/GoogleChrome/lighthouse/pull/5101/files#r186168840
-function findValueInMetricsAuditFn(metricName) {
-  return auditResults => {
-    const metricsAudit = auditResults.metrics;
-    if (!metricsAudit || !metricsAudit.details || !metricsAudit.details.items) return;
+/** @typedef {import('../../audits/metrics.js').UberMetricsItem} UberMetricsItem */
 
-    const values = metricsAudit.details.items[0];
-    return values && values[metricName];
-  };
+/**
+ * @param {LH.Audit.Results} auditResults
+ * @return {UberMetricsItem|undefined}
+ */
+function getUberMetrics(auditResults) {
+  const metricsAudit = auditResults.metrics;
+  if (!metricsAudit || !metricsAudit.details || !metricsAudit.details.items) return;
+
+  return metricsAudit.details.items[0];
 }
 
 class Metrics {
+  /**
+   * @param {Array<LH.TraceEvent>} traceEvents
+   * @param {LH.Audit.Results} auditResults
+   */
   constructor(traceEvents, auditResults) {
     this._traceEvents = traceEvents;
     this._auditResults = auditResults;
@@ -28,109 +32,107 @@ class Metrics {
 
   /**
    * Returns simplified representation of all metrics
-   * @return {!Array<{getTs: Function, id: string, name: string}>} metrics to consider
+   * @return {Array<{id: string, name: string, tsKey: keyof UberMetricsItem}>} metrics to consider
    */
   static get metricsDefinitions() {
     return [
       {
         name: 'Navigation Start',
         id: 'navstart',
-        getTs: findValueInMetricsAuditFn('observedNavigationStartTs'),
-        getTiming: findValueInMetricsAuditFn('observedNavigationStart'),
+        tsKey: 'observedNavigationStartTs',
       },
       {
         name: 'First Contentful Paint',
         id: 'ttfcp',
-        getTs: findValueInMetricsAuditFn('observedFirstContentfulPaintTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstContentfulPaint'),
+        tsKey: 'observedFirstContentfulPaintTs',
       },
       {
         name: 'First Meaningful Paint',
         id: 'ttfmp',
-        getTs: findValueInMetricsAuditFn('observedFirstMeaningfulPaintTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstMeaningfulPaint'),
+        tsKey: 'observedFirstMeaningfulPaintTs',
       },
       {
         name: 'Speed Index',
         id: 'si',
-        getTs: findValueInMetricsAuditFn('observedSpeedIndexTs'),
-        getTiming: findValueInMetricsAuditFn('observedSpeedIndex'),
+        tsKey: 'observedSpeedIndexTs',
       },
       {
         name: 'First Visual Change',
         id: 'fv',
-        getTs: findValueInMetricsAuditFn('observedFirstVisualChangeTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstVisualChange'),
+        tsKey: 'observedFirstVisualChangeTs',
       },
       {
         name: 'Visually Complete 100%',
         id: 'vc100',
-        getTs: findValueInMetricsAuditFn('observedLastVisualChangeTs'),
-        getTiming: findValueInMetricsAuditFn('observedLastVisualChange'),
+        tsKey: 'observedLastVisualChangeTs',
       },
       {
         name: 'First CPU Idle',
         id: 'ttfi',
-        getTs: findValueInMetricsAuditFn('firstCPUIdleTs'),
-        getTiming: findValueInMetricsAuditFn('firstCPUIdle'),
+        tsKey: 'firstCPUIdleTs',
       },
       {
         name: 'Interactive',
         id: 'tti',
-        getTs: findValueInMetricsAuditFn('interactiveTs'),
-        getTiming: findValueInMetricsAuditFn('interactive'),
+        tsKey: 'interactiveTs',
       },
       {
         name: 'End of Trace',
         id: 'eot',
-        getTs: findValueInMetricsAuditFn('observedTraceEndTs'),
-        getTiming: findValueInMetricsAuditFn('observedTraceEnd'),
+        tsKey: 'observedTraceEndTs',
       },
       {
         name: 'On Load',
         id: 'onload',
-        getTs: findValueInMetricsAuditFn('observedLoadTs'),
-        getTiming: findValueInMetricsAuditFn('observedLoad'),
+        tsKey: 'observedLoadTs',
       },
       {
         name: 'DOM Content Loaded',
         id: 'dcl',
-        getTs: findValueInMetricsAuditFn('observedDomContentLoadedTs'),
-        getTiming: findValueInMetricsAuditFn('observedDomContentLoaded'),
+        tsKey: 'observedDomContentLoadedTs',
       },
     ];
   }
 
   /**
    * Returns simplified representation of all metrics' timestamps from monotonic clock
-   * @return {!Array<{ts: number, id: string, name: string}>} metrics to consider
+   * @return {Array<{ts: number, id: string, name: string}>} metrics to consider
    */
   gatherMetrics() {
-    const metricDfns = Metrics.metricsDefinitions;
+    const uberMetrics = getUberMetrics(this._auditResults);
+    if (!uberMetrics) {
+      return [];
+    }
+
+    /** @type {Array<{ts: number, id: string, name: string}>} */
     const resolvedMetrics = [];
-    metricDfns.forEach(metric => {
-      // try/catch in case auditResults is missing a particular audit result
-      try {
-        resolvedMetrics.push({
-          id: metric.id,
-          name: metric.name,
-          ts: metric.getTs(this._auditResults),
-        });
-      } catch (e) {
-        log.error('pwmetrics-events', `${metric.name} timestamp not found: ${e.message}`);
+    Metrics.metricsDefinitions.forEach(metric => {
+      // Skip if auditResults is missing a particular audit result
+      const ts = uberMetrics[metric.tsKey];
+      if (ts === undefined) {
+        log.error('pwmetrics-events', `${metric.name} timestamp not found`);
+        return;
       }
+
+      resolvedMetrics.push({
+        id: metric.id,
+        name: metric.name,
+        ts,
+      });
     });
+
     return resolvedMetrics;
   }
 
   /**
    * Get the full trace event for our navigationStart
-   * @param {!Array<{ts: number, id: string, name: string}>} metrics
+   * @param {Array<{ts: number, id: string, name: string}>} metrics
+   * @return {LH.TraceEvent|undefined}
    */
-  identifyNavigationStartEvt(metrics) {
+  getNavigationStartEvt(metrics) {
     const navStartMetric = metrics.find(e => e.id === 'navstart');
     if (!navStartMetric) return;
-    this._navigationStartEvt = this._traceEvents.find(
+    return this._traceEvents.find(
       e => e.name === 'navigationStart' && e.ts === navStartMetric.ts
     );
   }
@@ -140,13 +142,14 @@ class Metrics {
    *     { "pid": 89922,"tid":1295,"ts":77176783452,"ph":"b","cat":"blink.user_timing","name":"innermeasure","args":{},"tts":1257886,"id":"0xe66c67"}
    *     { "pid": 89922,"tid":1295,"ts":77176882592,"ph":"e","cat":"blink.user_timing","name":"innermeasure","args":{},"tts":1257898,"id":"0xe66c67"}
    * @param {{ts: number, id: string, name: string}} metric
-   * @return {!Array} Pair of trace events (start/end)
+   * @param {LH.TraceEvent} navigationStartEvt
+   * @return {Array<LH.TraceEvent>} Pair of trace events (start/end)
    */
-  synthesizeEventPair(metric) {
+  synthesizeEventPair(metric, navigationStartEvt) {
     // We'll masquerade our fake events to look mostly like navigationStart
     const eventBase = {
-      pid: this._navigationStartEvt.pid,
-      tid: this._navigationStartEvt.tid,
+      pid: navigationStartEvt.pid,
+      tid: navigationStartEvt.tid,
       cat: 'blink.user_timing',
       name: metric.name,
       args: {},
@@ -154,33 +157,34 @@ class Metrics {
       id: `0x${((Math.random() * 1000000) | 0).toString(16)}`,
     };
     const fakeMeasureStartEvent = Object.assign({}, eventBase, {
-      ts: this._navigationStartEvt.ts,
+      ts: navigationStartEvt.ts,
       ph: 'b',
     });
     const fakeMeasureEndEvent = Object.assign({}, eventBase, {
       ts: metric.ts,
       ph: 'e',
     });
-    return [fakeMeasureStartEvent, fakeMeasureEndEvent];
+    return /** @type {Array<LH.TraceEvent>} */ ([fakeMeasureStartEvent, fakeMeasureEndEvent]);
   }
 
   /**
-   * @returns {Array<LH.TraceEvent>} User timing raw trace event pairs
+   * @return {Array<LH.TraceEvent>} User timing raw trace event pairs
    */
   generateFakeEvents() {
-    const fakeEvents = [];
     const metrics = this.gatherMetrics();
     if (metrics.length === 0) {
       log.error('metrics-events', 'Metrics collection had errors, not synthetizing trace events');
       return [];
     }
 
-    this.identifyNavigationStartEvt(metrics);
-    if (!this._navigationStartEvt) {
+    const navigationStartEvt = this.getNavigationStartEvt(metrics);
+    if (!navigationStartEvt) {
       log.error('pwmetrics-events', 'Reference navigationStart not found');
       return [];
     }
 
+    /** @type {Array<LH.TraceEvent>} */
+    const fakeEvents = [];
     metrics.forEach(metric => {
       if (metric.id === 'navstart') {
         return;
@@ -190,7 +194,7 @@ class Metrics {
         return;
       }
       log.verbose('pwmetrics-events', `Sythesizing trace events for ${metric.name}`);
-      fakeEvents.push(...this.synthesizeEventPair(metric));
+      fakeEvents.push(...this.synthesizeEventPair(metric, navigationStartEvt));
     });
     return fakeEvents;
   }
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index 25976cce4..0fa8be23b 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -43,23 +43,11 @@ class CategoryRenderer {
   /**
    * Display info per top-level clump. Define on class to avoid race with Util init.
    */
-  get _clumpDisplayInfo() {
+  get _clumpTitles() {
     return {
-      failed: {
-        className: 'lh-clump--failed',
-      },
-      manual: {
-        title: Util.UIStrings.manualAuditsGroupTitle,
-        className: 'lh-clump--manual',
-      },
-      passed: {
-        title: Util.UIStrings.passedAuditsGroupTitle,
-        className: 'lh-clump--passed',
-      },
-      notApplicable: {
-        title: Util.UIStrings.notApplicableAuditsGroupTitle,
-        className: 'lh-clump--notapplicable',
-      },
+      manual: Util.UIStrings.manualAuditsGroupTitle,
+      passed: Util.UIStrings.passedAuditsGroupTitle,
+      notApplicable: Util.UIStrings.notApplicableAuditsGroupTitle,
     };
   }
 
@@ -183,20 +171,13 @@ class CategoryRenderer {
    * Renders the group container for a group of audits. Individual audit elements can be added
    * directly to the returned element.
    * @param {LH.Result.ReportGroup} group
-   * @param {{expandable: boolean, itemCount?: number}} opts
    * @return {Element}
    */
-  renderAuditGroup(group, opts) {
-    const expandable = opts.expandable;
-    const groupEl = this.dom.createElement(expandable ? 'details' : 'div', 'lh-audit-group');
-    const summaryEl = this.dom.createChildOf(groupEl, expandable ? 'summary' : 'div');
+  renderAuditGroup(group) {
+    const groupEl = this.dom.createElement('div', 'lh-audit-group');
+    const summaryEl = this.dom.createChildOf(groupEl, 'div');
     const summaryInnerEl = this.dom.createChildOf(summaryEl, 'div', 'lh-audit-group__summary');
     const headerEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__header');
-    const itemCountEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__itemcount');
-    if (expandable) {
-      const chevronEl = summaryInnerEl.appendChild(this._createChevron());
-      chevronEl.title = Util.UIStrings.auditGroupExpandTooltip;
-    }
 
     if (group.description) {
       const auditGroupDescription = this.dom.createElement('div', 'lh-audit-group__description');
@@ -205,10 +186,6 @@ class CategoryRenderer {
     }
     headerEl.textContent = group.title;
 
-    if (opts.itemCount) {
-      // TODO(i18n): support multiple locales here
-      itemCountEl.textContent = `${opts.itemCount} audits`;
-    }
     return groupEl;
   }
 
@@ -217,10 +194,9 @@ class CategoryRenderer {
    * array of audit and audit-group elements.
    * @param {Array<LH.ReportResult.AuditRef>} auditRefs
    * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
-   * @param {{expandable: boolean}} opts
    * @return {Array<Element>}
    */
-  _renderGroupedAudits(auditRefs, groupDefinitions, opts) {
+  _renderGroupedAudits(auditRefs, groupDefinitions) {
     // Audits grouped by their group (or under notAGroup).
     /** @type {Map<string, Array<LH.ReportResult.AuditRef>>} */
     const grouped = new Map();
@@ -252,7 +228,7 @@ class CategoryRenderer {
 
       // Push grouped audits as a group.
       const groupDef = groupDefinitions[groupId];
-      const auditGroupElem = this.renderAuditGroup(groupDef, opts);
+      const auditGroupElem = this.renderAuditGroup(groupDef);
       for (const auditRef of groupAuditRefs) {
         auditGroupElem.appendChild(this.renderAudit(auditRef, index++));
       }
@@ -272,52 +248,46 @@ class CategoryRenderer {
    */
   renderUnexpandableClump(auditRefs, groupDefinitions) {
     const clumpElement = this.dom.createElement('div');
-    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable: false});
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions);
     elements.forEach(elem => clumpElement.appendChild(elem));
     return clumpElement;
   }
 
   /**
-   * Renders a clump (a grouping of groups), under a status of failed, manual,
-   * passed, or notApplicable. The result ends up something like:
-   *
-   * clump (e.g. 'failed')
-   *   ├── audit 1 (w/o group)
-   *   ├── audit 2 (w/o group)
-   *   ├── audit group
-   *   |  ├── audit 3
-   *   |  └── audit 4
-   *   └── audit group
-   *      ├── audit 5
-   *      └── audit 6
-   * clump (e.g. 'manual')
-   *   ├── …
-   *   ⋮
-   * @param {TopLevelClumpId} clumpId
-   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, groupDefinitions: Object<string, LH.Result.ReportGroup>, description?: string}} clumpOpts
+   * Take a set of audits and render in a top-level, expandable clump that starts
+   * in a collapsed state.
+   * @param {Exclude<TopLevelClumpId, 'failed'>} clumpId
+   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, description?: string}} clumpOpts
    * @return {Element}
    */
-  renderClump(clumpId, {auditRefs, groupDefinitions, description}) {
-    if (clumpId === 'failed') {
-      // Failed audit clump is always expanded and not nested in an lh-audit-group.
-      const failedElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
-      failedElem.classList.add('lh-clump', this._clumpDisplayInfo.failed.className);
-      return failedElem;
+  renderClump(clumpId, {auditRefs, description}) {
+    const clumpTmpl = this.dom.cloneTemplate('#tmpl-lh-clump', this.templateContext);
+    const clumpElement = this.dom.find('.lh-clump', clumpTmpl);
+
+    const summaryInnerEl = this.dom.find('.lh-audit-group__summary', clumpElement);
+    const chevronEl = summaryInnerEl.appendChild(this._createChevron());
+    chevronEl.title = Util.UIStrings.auditGroupExpandTooltip;
+
+    const headerEl = this.dom.find('.lh-audit-group__header', clumpElement);
+    const title = this._clumpTitles[clumpId];
+    headerEl.textContent = title;
+    if (description) {
+      const markdownDescriptionEl = this.dom.convertMarkdownLinkSnippets(description);
+      const auditGroupDescription = this.dom.createElement('div', 'lh-audit-group__description');
+      auditGroupDescription.appendChild(markdownDescriptionEl);
+      clumpElement.appendChild(auditGroupDescription);
     }
 
-    const expandable = true;
-    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable});
-
-    const clumpInfo = this._clumpDisplayInfo[clumpId];
-    // TODO: renderAuditGroup shouldn't be used to render a clump (since it *contains* audit groups).
-    const groupDef = {title: clumpInfo.title, description};
-    const opts = {expandable, itemCount: auditRefs.length};
-    const clumpElem = this.renderAuditGroup(groupDef, opts);
-    clumpElem.classList.add('lh-clump', clumpInfo.className);
+    const itemCountEl = this.dom.find('.lh-audit-group__itemcount', clumpElement);
+    // TODO(i18n): support multiple locales here
+    itemCountEl.textContent = `${auditRefs.length} audits`;
 
-    elements.forEach(elem => clumpElem.appendChild(elem));
+    // Add all audit results to the clump.
+    const auditElements = auditRefs.map(this.renderAudit.bind(this));
+    clumpElement.append(...auditElements);
 
-    return clumpElem;
+    clumpElement.classList.add(`lh-clump--${clumpId.toLowerCase()}`);
+    return clumpElement;
   }
 
   /**
@@ -382,6 +352,23 @@ class CategoryRenderer {
   }
 
   /**
+   * Renders a set of top level sections (clumps), under a status of failed, manual,
+   * passed, or notApplicable. The result ends up something like:
+   *
+   * failed clump
+   *   ├── audit 1 (w/o group)
+   *   ├── audit 2 (w/o group)
+   *   ├── audit group
+   *   |  ├── audit 3
+   *   |  └── audit 4
+   *   └── audit group
+   *      ├── audit 5
+   *      └── audit 6
+   * other clump (e.g. 'manual')
+   *   ├── audit 1
+   *   ├── audit 2
+   *   ├── …
+   *   ⋮
    * @param {LH.ReportResult.Category} category
    * @param {Object<string, LH.Result.ReportGroup>} [groupDefinitions]
    * @return {Element}
@@ -408,12 +395,18 @@ class CategoryRenderer {
     }
 
     // Render each clump.
-    for (const [clumpId, clumpRefs] of clumps) {
-      if (clumpRefs.length === 0) continue;
+    for (const [clumpId, auditRefs] of clumps) {
+      if (auditRefs.length === 0) continue;
+
+      if (clumpId === 'failed') {
+        const clumpElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
+        clumpElem.classList.add(`lh-clump--failed`);
+        element.appendChild(clumpElem);
+        continue;
+      }
 
       const description = clumpId === 'manual' ? category.manualDescription : undefined;
-      const clumpElem = this.renderClump(clumpId, {auditRefs: clumpRefs, groupDefinitions,
-        description});
+      const clumpElem = this.renderClump(clumpId, {auditRefs, description});
       element.appendChild(clumpElem);
     }
 
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 252b66701..870bdfc4e 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -129,7 +129,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
 
     // Metrics
     const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
-    const metricAuditsEl = this.renderAuditGroup(groups.metrics, {expandable: false});
+    const metricAuditsEl = this.renderAuditGroup(groups.metrics);
 
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
     const otherMetrics = metricAudits.filter(a => a.weight < 3);
@@ -176,7 +176,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
       const wastedMsValues = opportunityAudits.map(audit => this._getWastedMs(audit));
       const maxWaste = Math.max(...wastedMsValues);
       const scale = Math.max(Math.ceil(maxWaste / 1000) * 1000, minimumScale);
-      const groupEl = this.renderAuditGroup(groups['load-opportunities'], {expandable: false});
+      const groupEl = this.renderAuditGroup(groups['load-opportunities']);
       const tmpl = this.dom.cloneTemplate('#tmpl-lh-opportunity-header', this.templateContext);
 
       this.dom.find('.lh-load-opportunity__col--one', tmpl).textContent =
@@ -202,7 +202,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
         });
 
     if (diagnosticAudits.length) {
-      const groupEl = this.renderAuditGroup(groups['diagnostics'], {expandable: false});
+      const groupEl = this.renderAuditGroup(groups['diagnostics']);
       diagnosticAudits.forEach((item, i) => groupEl.appendChild(this.renderAudit(item, i)));
       groupEl.classList.add('lh-audit-group--diagnostics');
       element.appendChild(groupEl);
diff --git a/lighthouse-core/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
index 690e8da89..7e07206f3 100644
--- a/lighthouse-core/report/html/renderer/pwa-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -40,7 +40,7 @@ class PwaCategoryRenderer extends CategoryRenderer {
     // Manual audits are still in a manual clump.
     const manualAuditRefs = auditRefs.filter(ref => ref.result.scoreDisplayMode === 'manual');
     const manualElem = this.renderClump('manual',
-      {auditRefs: manualAuditRefs, groupDefinitions, description: category.manualDescription});
+      {auditRefs: manualAuditRefs, description: category.manualDescription});
     categoryElem.appendChild(manualElem);
 
     return categoryElem;
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 8b9559fa4..72f1711fd 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -206,6 +206,15 @@ class ReportRenderer {
       categories.appendChild(renderer.render(category, report.categoryGroups));
     }
 
+    // Fireworks
+    const scoresAll100 = report.reportCategories.every(cat => cat.score === 1);
+    if (scoresAll100) {
+      headerContainer.classList.add('score100');
+      this._dom.find('.lh-header', headerContainer).addEventListener('click', _ => {
+        headerContainer.classList.toggle('fireworks-paused');
+      });
+    }
+
     if (scoreHeader) {
       const defaultGauges = [];
       const customGauges = [];
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 16b1fd3f8..bfc1b627c 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -241,10 +241,12 @@ class ReportUIFeatures {
     scoreShadow.style.transform = `scaleY(${1 - scrollPct * 0.2})`;
 
     // Fade & move the scorescale
-    const scoreScalePositionDelta = 32;
-    const scoreScale = this._dom.find('.lh-scorescale', scoresContainer);
-    scoreScale.style.opacity = `${1 - scrollPct}`;
-    scoreScale.style.transform = `translateY(${scrollPct * -scoreScalePositionDelta}px)`;
+    try {
+      const scoreScalePositionDelta = 32;
+      const scoreScale = this._dom.find('.lh-scorescale', scoresContainer);
+      scoreScale.style.opacity = `${1 - scrollPct}`;
+      scoreScale.style.transform = `translateY(${scrollPct * -scoreScalePositionDelta}px)`;
+    } catch (e) {}
 
     // Move the toolbar & export
     this.toolbar.style.transform = `translateY(${heightDiff * scrollPct}px)`;
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 07b8cb9ee..ea4a195e1 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -788,7 +788,7 @@
 
 .lh-scores-header .lh-gauge__wrapper,
 .lh-scores-header .lh-gauge--pwa__wrapper {
-  margin: 0 4px;
+  padding: 0 4px;
 }
 
 .lh-scores-header .lh-gauge--pwa__wrapper {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 0971ccd1f..1f90384b3 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -52,6 +52,20 @@ limitations under the License.
   </div>
 </template>
 
+<!-- Lighthouse clump -->
+<template id="tmpl-lh-clump">
+  <!-- TODO: group classes shouldn't be reused for clumps. -->
+  <details class="lh-clump lh-audit-group">
+    <summary>
+      <div class="lh-audit-group__summary">
+        <div class="lh-audit-group__header"></div>
+        <div class="lh-audit-group__itemcount"></div>
+        <div class=""></div>
+      </div>
+    </summary>
+  </details>
+</template>
+
 <!-- Lighthouse audit -->
 <template id="tmpl-lh-audit">
   <div class="lh-audit">
@@ -163,6 +177,7 @@ limitations under the License.
       top: 0;
       width: 100%;
       will-change: transform;
+      contain: strict;
     }
     .lh-lighthouse {
       position: absolute;
@@ -352,8 +367,91 @@ limitations under the License.
       77.0001% { transform: translateX(195px); }
       100% { transform: translateX(0px); }
     } */
+
+    .score100 .lh-header-bg {
+      background-color: hsl(234, 64%, 19%);
+    }
+    .score100 .lh-metadata, .score100 .lh-toolbar__metadata, .score100 .lh-product-info {
+      color: #fff;
+    }
+    .score100 .lh-config {
+      color: #eee;
+    }
+
+    /* CSS Fireworks. Originally by Eddie Lin
+       https://codepen.io/paulirish/pen/yEVMbP
+    */
+    .pyro {
+      display: none;
+    }
+    .score100 .pyro {
+      display: block;
+    }
+    .score100 .lh-lighthouse stop:first-child {
+      stop-color: hsla(200, 12%, 95%, 0);
+    }
+    .score100 .lh-lighthouse stop:last-child {
+      stop-color: hsla(65, 81%, 76%, 1);
+    }
+
+    .pyro > .before, .pyro > .after {
+      position: absolute;
+      width: 5px;
+      height: 5px;
+      border-radius: 2.5px;
+      box-shadow: 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff;
+      animation: 1s bang ease-out infinite backwards,  1s gravity ease-in infinite backwards,  5s position linear infinite backwards;
+      animation-delay: 1s, 1s, 1s;
+    }
+
+    .pyro > .after {
+      animation-delay: 2.25s, 2.25s, 2.25s;
+      animation-duration: 1.25s, 1.25s, 6.25s;
+    }
+    .fireworks-paused .pyro > div {
+      animation-play-state: paused;
+    }
+
+    @keyframes bang {
+      to {
+        box-shadow: -70px -115.67px #47ebbc, -28px -99.67px #eb47a4, 58px -31.67px #7eeb47, 13px -141.67px #eb47c5, -19px 6.33px #7347eb, -2px -74.67px #ebd247, 24px -151.67px #eb47e0, 57px -138.67px #b4eb47, -51px -104.67px #479eeb, 62px 8.33px #ebcf47, -93px 0.33px #d547eb, -16px -118.67px #47bfeb, 53px -84.67px #47eb83, 66px -57.67px #eb47bf, -93px -65.67px #91eb47, 30px -13.67px #86eb47, -2px -59.67px #83eb47, -44px 1.33px #eb47eb, 61px -58.67px #47eb73, 5px -22.67px #47e8eb, -66px -28.67px #ebe247, 42px -123.67px #eb5547, -75px 26.33px #7beb47, 15px -52.67px #a147eb, 36px -51.67px #eb8347, -38px -12.67px #eb5547, -46px -59.67px #47eb81, 78px -114.67px #eb47ba, 15px -156.67px #eb47bf, -36px 1.33px #eb4783, -72px -86.67px #eba147, 31px -46.67px #ebe247, -68px 29.33px #47e2eb, -55px 19.33px #ebe047, -56px 27.33px #4776eb, -13px -91.67px #eb5547, -47px -138.67px #47ebc7, -18px -96.67px #eb47ac, 11px -88.67px #4783eb, -67px -28.67px #47baeb, 53px 10.33px #ba47eb, 11px 19.33px #5247eb, -5px -11.67px #eb4791, -68px -4.67px #47eba7, 95px -37.67px #eb478b, -67px -162.67px #eb5d47, -54px -120.67px #eb6847, 49px -12.67px #ebe047, 88px 8.33px #47ebda, 97px 33.33px #eb8147, 6px -71.67px #ebbc47;
+      }
+    }
+    @keyframes gravity {
+      to {
+        transform: translateY(80px);
+        opacity: 0;
+      }
+    }
+    @keyframes position {
+      0%, 19.9% {
+        margin-top: 4%;
+        margin-left: 47%;
+      }
+      20%, 39.9% {
+        margin-top: 7%;
+        margin-left: 30%;
+      }
+      40%, 59.9% {
+        margin-top: 6%;
+        margin-left: 70%;
+      }
+      60%, 79.9% {
+        margin-top: 3%;
+        margin-left: 20%;
+      }
+      80%, 99.9% {
+        margin-top: 3%;
+        margin-left: 80%;
+      }
+    }
   </style>
-  <div class="lh-header-bg"></div>
+  <div class="lh-header-bg">
+    <div class="pyro">
+      <div class="before"></div>
+      <div class="after"></div>
+    </div>
+  </div>
   
 
   <div class="lh-header-container">
@@ -370,12 +468,14 @@ limitations under the License.
             </linearGradient>
           </defs>
           <g mask="url(#a)">
+            <!-- lighthouse building elements -->
             <path d="M95 47h24v2H95z" fill="#ec5548"/>
-            <path d="M98 49h18v11H98z" fill="#fbc21b"/>
             <path d="M95 59h24v7H95z" fill="#ec5548"/>
             <path d="M97.63 66h19.74l2.63 47H95z" fill="#fff"/>
             <path d="M107 38a10 10 0 0 1 10 10v1H97v-1a10 10 0 0 1 10-10zM96.77 82.23l21-10.7.63 11.87-22.31 11.87zM95 110.8L119.1 98l.9 14H95z" fill="#ec5548"/>
             <path d="M0 148a177.58 177.58 0 0 1 217 0z" fill="#e8eaed"/>
+            <!-- lighthouse beam elements -->
+            <path d="M98 49h18v11H98z" fill="#fbc21b"/>
             <path d="M103 49a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5z" fill="#fef0c8"/>
             <path d="M7 16l91 33.18v10L7 93z" fill="url(#b)"/>
           </g>
diff --git a/readme.md b/readme.md
index c9882b27f..d0cc15dc9 100644
--- a/readme.md
+++ b/readme.md
@@ -106,7 +106,7 @@ Examples:
   lighthouse <url> --output=json --output-path=./report.json --save-assets  Save trace, devtoolslog, and named JSON report.
   lighthouse <url> --disable-device-emulation                               Disable device emulation and all throttling.
     --throttling-method=provided
-  lighthouse <url> --chrome-flags="--window-size=412,732"                   Launch Chrome with a specific window size
+  lighthouse <url> --chrome-flags="--window-size=412,660"                   Launch Chrome with a specific window size
   lighthouse <url> --quiet --chrome-flags="--headless"                      Launch Headless Chrome, turn off logging
   lighthouse <url> --extra-headers "{\"Cookie\":\"monster=blue\"}"          Stringify\'d JSON HTTP Header key/value pairs to send in requests
   lighthouse <url> --extra-headers=./path/to/file.json                      Path to JSON file of HTTP Header key/value pairs to send in requests
@@ -271,6 +271,8 @@ This section details services that have integrated Lighthouse data. If you're wo
 
 * **[SpeedCurve](https://speedcurve.com)** — SpeedCurve is a tool for continuously monitoring web performance across different browsers, devices, and regions. It can aggregate any metric including Lighthouse scores across multiple pages and sites, and allows you to set performance budgets with Slack or email alerts. SpeedCurve is a paid product with a free 30-day trial.
 
+* **[Speedrank](https://speedrank.app)** - Speedrank monitors the performance of your website in the background. It displays Lighthouse reports over time and delivers recommendations for improvement. Speedrank is a paid product with 14-day-trial.
+
 * **[Treo](https://treo.sh)** - Treo is Lighthouse as a Service. It provides regression testing, geographical regions, custom networks, and integrations with GitHub & Slack. Treo is a paid product with plans for solo-developers and teams.
 
 * **[Web Page Test](https://www.webpagetest.org)** — An [open source](https://github.com/WPO-Foundation/webpagetest) tool for measuring and analyzing the performance of web pages on real devices. Users can choose to produce a Lighthouse report alongside the analysis of WebPageTest results.


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/error-expectations.js b/lighthouse-cli/test/smokehouse/error-expectations.js
index 40d057806..8d1a59290 100644
--- a/lighthouse-cli/test/smokehouse/error-expectations.js
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -15,4 +15,10 @@ module.exports = [
     errorCode: 'PAGE_HUNG',
     audits: {},
   },
+  {
+    requestedUrl: 'https://expired.badssl.com',
+    finalUrl: 'https://expired.badssl.com',
+    errorCode: 'INSECURE_DOCUMENT_REQUEST',
+    audits: {},
+  },
 ];
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 603d71aa0..4a304496e 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -32,6 +32,7 @@ const log = require('lighthouse-logger');
 
 const PROTOCOL_TIMEOUT_EXIT_CODE = 67;
 const PAGE_HUNG_EXIT_CODE = 68;
+const INSECURE_DOCUMENT_REQUEST_EXIT_CODE = 69;
 const RETRIES = 3;
 const NUMERICAL_EXPECTATION_REGEXP = /^(<=?|>=?)((\d|\.)+)$/;
 
@@ -103,7 +104,9 @@ function runLighthouse(url, configPath, isDebug) {
   if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
     console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
     process.exit(1);
-  } else if (runResults.status !== 0 && runResults.status !== PAGE_HUNG_EXIT_CODE) {
+  } else if (runResults.status !== 0
+     && runResults.status !== PAGE_HUNG_EXIT_CODE
+     && runResults.status !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE) {
     console.error(`Lighthouse run failed with exit code ${runResults.status}. stderr to follow:`);
     console.error(runResults.stderr);
     process.exit(runResults.status);
@@ -118,6 +121,10 @@ function runLighthouse(url, configPath, isDebug) {
     return {requestedUrl: url, finalUrl: url, errorCode: 'PAGE_HUNG', audits: {}};
   }
 
+  if (runResults.status === INSECURE_DOCUMENT_REQUEST_EXIT_CODE) {
+    return {requestedUrl: url, finalUrl: url, errorCode: 'INSECURE_DOCUMENT_REQUEST', audits: {}};
+  }
+
   const lhr = fs.readFileSync(outputPath, 'utf8');
   if (isDebug) {
     console.log('LHR output available at: ', outputPath);
diff --git a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
index 777cdf1b6..8af6b351a 100644
--- a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
+++ b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
@@ -12,9 +12,9 @@
   "Viewport": null,
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   }
 }
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index 3229755d6..0030b03e2 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -15,16 +15,15 @@ const assert = require('assert');
 const EventEmitter = require('events').EventEmitter;
 const {protocolGetVersionResponse} = require('./fake-driver');
 
-const connection = new Connection();
-const driverStub = new Driver(connection);
-
 const redirectDevtoolsLog = require('../fixtures/wikipedia-redirect.devtoolslog.json');
 const MAX_WAIT_FOR_PROTOCOL = 20;
 
 function createOnceStub(events) {
   return (eventName, cb) => {
     if (events[eventName]) {
-      return cb(events[eventName]);
+      // wait a tick b/c real events never fire immediately
+      setTimeout(_ => cb(events[eventName]), 0);
+      return;
     }
 
     throw Error(`Stub not implemented: ${eventName}`);
@@ -53,7 +52,7 @@ function createOnceMethodResponse(method, response) {
   sendCommandMockResponses.set(method, response);
 }
 
-connection.sendCommand = function(command, params) {
+function sendCommandStub(command, params) {
   sendCommandParams.push({command, params});
 
   if (sendCommandMockResponses.has(command)) {
@@ -95,23 +94,35 @@ connection.sendCommand = function(command, params) {
     case 'Network.getResponseBody':
       return new Promise(res => setTimeout(res, MAX_WAIT_FOR_PROTOCOL + 20));
     case 'Page.enable':
+    case 'Page.navigate':
+    case 'Page.setLifecycleEventsEnabled':
     case 'Network.enable':
     case 'Tracing.start':
     case 'ServiceWorker.enable':
     case 'ServiceWorker.disable':
+    case 'Security.enable':
+    case 'Security.disable':
     case 'Network.setExtraHTTPHeaders':
     case 'Network.emulateNetworkConditions':
     case 'Emulation.setCPUThrottlingRate':
+    case 'Emulation.setScriptExecutionDisabled':
       return Promise.resolve({});
     case 'Tracing.end':
       return Promise.reject(new Error('tracing not started'));
     default:
       throw Error(`Stub not implemented: ${command}`);
   }
-};
+}
 
 /* eslint-env jest */
 
+let driverStub;
+beforeEach(() => {
+  const connection = new Connection();
+  connection.sendCommand = sendCommandStub;
+  driverStub = new Driver(connection);
+});
+
 describe('Browser Driver', () => {
   beforeEach(() => {
     sendCommandParams = [];
@@ -535,4 +546,75 @@ describe('Multiple tab check', () => {
       });
     });
   });
+
+  describe('Security check', () => {
+    it('does not reject when page is secure', async () => {
+      const secureSecurityState = {
+        explanations: [],
+        securityState: 'secure',
+      };
+      driverStub.on = driverStub.once = createOnceStub({
+        'Security.securityStateChanged': secureSecurityState,
+        'Page.loadEventFired': {},
+        'Page.domContentEventFired': {},
+      });
+
+      const startUrl = 'https://www.example.com';
+      const loadOptions = {
+        waitForLoad: true,
+        passContext: {
+          passConfig: {
+            networkQuietThresholdMs: 1,
+          },
+          settings: {
+            maxWaitForLoad: 1,
+          },
+        },
+      };
+      await driverStub.gotoURL(startUrl, loadOptions);
+    });
+
+    it('rejects when page is insecure', async () => {
+      const insecureSecurityState = {
+        explanations: [
+          {
+            description: 'reason 1.',
+            securityState: 'insecure',
+          },
+          {
+            description: 'blah.',
+            securityState: 'info',
+          },
+          {
+            description: 'reason 2.',
+            securityState: 'insecure',
+          },
+        ],
+        securityState: 'insecure',
+      };
+      driverStub.on = createOnceStub({
+        'Security.securityStateChanged': insecureSecurityState,
+      });
+
+      const startUrl = 'https://www.example.com';
+      const loadOptions = {
+        waitForLoad: true,
+        passContext: {
+          passConfig: {
+            networkQuietThresholdMs: 1,
+          },
+        },
+      };
+
+      try {
+        await driverStub.gotoURL(startUrl, loadOptions);
+        assert.fail('security check should have rejected');
+      } catch (err) {
+        assert.equal(err.message, 'INSECURE_DOCUMENT_REQUEST');
+        assert.equal(err.code, 'INSECURE_DOCUMENT_REQUEST');
+        /* eslint-disable-next-line max-len */
+        expect(err.friendlyMessage).toBeDisplayString('The URL you have provided does not have valid security credentials. reason 1. reason 2.');
+      }
+    });
+  });
 });
diff --git a/lighthouse-core/test/gather/fake-driver.js b/lighthouse-core/test/gather/fake-driver.js
index 75ce1e2f3..f90ed3b0f 100644
--- a/lighthouse-core/test/gather/fake-driver.js
+++ b/lighthouse-core/test/gather/fake-driver.js
@@ -81,14 +81,6 @@ const fakeDriver = {
   setExtraHTTPHeaders() {
     return Promise.resolve();
   },
-  listenForSecurityStateChanges() {
-    return Promise.resolve();
-  },
-  getSecurityState() {
-    return Promise.resolve({
-      securityState: 'secure',
-    });
-  },
 };
 
 module.exports = fakeDriver;
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 68c2076e1..ae3fcd43b 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -322,7 +322,6 @@ describe('GatherRunner', function() {
       clearDataForOrigin: createCheck('calledClearStorage'),
       blockUrlPatterns: asyncFunc,
       setExtraHTTPHeaders: asyncFunc,
-      listenForSecurityStateChanges: asyncFunc,
     };
 
     return GatherRunner.setupDriver(driver, {settings: {}}).then(_ => {
@@ -382,7 +381,6 @@ describe('GatherRunner', function() {
       clearDataForOrigin: createCheck('calledClearStorage'),
       blockUrlPatterns: asyncFunc,
       setExtraHTTPHeaders: asyncFunc,
-      listenForSecurityStateChanges: asyncFunc,
     };
 
     return GatherRunner.setupDriver(driver, {
@@ -696,44 +694,6 @@ describe('GatherRunner', function() {
     });
   });
 
-  describe('#assertNoSecurityIssues', () => {
-    it('succeeds when page is secure', () => {
-      const secureSecurityState = {
-        securityState: 'secure',
-      };
-      GatherRunner.assertNoSecurityIssues(secureSecurityState);
-    });
-
-    it('fails when page is insecure', () => {
-      const insecureSecurityState = {
-        explanations: [
-          {
-            description: 'reason 1',
-            securityState: 'insecure',
-          },
-          {
-            description: 'blah.',
-            securityState: 'info',
-          },
-          {
-            description: 'reason 2',
-            securityState: 'insecure',
-          },
-        ],
-        securityState: 'insecure',
-      };
-      try {
-        GatherRunner.assertNoSecurityIssues(insecureSecurityState);
-        assert.fail('expected INSECURE_DOCUMENT_REQUEST LHError');
-      } catch (err) {
-        assert.equal(err.message, 'INSECURE_DOCUMENT_REQUEST');
-        assert.equal(err.code, 'INSECURE_DOCUMENT_REQUEST');
-        expect(err.friendlyMessage)
-          .toBeDisplayString(/The URL.*security credentials.*reason 1 reason 2/);
-      }
-    });
-  });
-
   describe('artifact collection', () => {
     // Make sure our gatherers never execute in parallel
     it('runs gatherer lifecycle methods strictly in sequence', async () => {
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index 8be1099a4..bbb554754 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -227,14 +227,16 @@ describe('CategoryRenderer', () => {
       assert.equal(failedAuditGroups.length, failedAuditTags.size);
     });
 
-    it('renders the passed audits grouped by group', () => {
+    it('renders the passed audits ungrouped', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
       const passedAudits = category.auditRefs.filter(audit =>
           audit.result.scoreDisplayMode !== 'notApplicable' && audit.result.score === 1);
-      const passedAuditTags = new Set(passedAudits.map(audit => audit.group));
 
       const passedAuditGroups = categoryDOM.querySelectorAll('.lh-clump--passed .lh-audit-group');
-      assert.equal(passedAuditGroups.length, passedAuditTags.size);
+      const passedAuditsElems = categoryDOM.querySelectorAll('.lh-clump--passed .lh-audit');
+
+      assert.equal(passedAuditGroups.length, 0);
+      assert.equal(passedAuditsElems.length, passedAudits.length);
     });
 
     it('renders all the audits', () => {
@@ -293,15 +295,20 @@ describe('CategoryRenderer', () => {
     });
 
     it('gives each group a selectable class', () => {
+      const categoryClone = JSON.parse(JSON.stringify(category));
+      // Force all results to be Failed for accurate counting of groups.
+      categoryClone.auditRefs.forEach(ref => {
+        ref.result.score = 0;
+        ref.result.scoreDisplayMode = 'binary';
+      });
       const categoryGroupIds = new Set(category.auditRefs.filter(a => a.group).map(a => a.group));
       assert.ok(categoryGroupIds.size > 6); // Ensure there's something to test.
 
-      const categoryElem = renderer.render(category, sampleResults.categoryGroups);
+      const categoryElem = renderer.render(categoryClone, sampleResults.categoryGroups);
 
       categoryGroupIds.forEach(groupId => {
         const selector = `.lh-audit-group--${groupId}`;
-        // Could be multiple results (e.g. found in both passed and failed clumps).
-        assert.ok(categoryElem.querySelectorAll(selector).length >= 1,
+        assert.equal(categoryElem.querySelectorAll(selector).length, 1,
           `could not find '${selector}'`);
       });
     });
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 2609dbc44..478d8dd81 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -81,6 +81,8 @@ describe('ReportRenderer', () => {
       assert.ok(output.querySelector('.lh-report'), 'has report body');
       assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
           sampleResults.reportCategories.length * 2, 'renders category gauges');
+      // no fireworks
+      assert.ok(output.querySelector('.score100') === null, 'has no fireworks treatment');
     });
 
     it('renders additional reports by replacing the existing one', () => {
@@ -180,6 +182,23 @@ describe('ReportRenderer', () => {
     assert.equal(renderer._templateContext, otherDocument);
   });
 
+  it('should render an all 100 report with fireworks', () => {
+    const container = renderer._dom._document.body;
+
+    sampleResults.reportCategories.forEach(element => {
+      element.score = 1;
+    });
+
+    const output = renderer.renderReport(sampleResults, container);
+    // standard checks
+    assert.ok(output.querySelector('.lh-header-sticky'), 'has a header');
+    assert.ok(output.querySelector('.lh-report'), 'has report body');
+    assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
+        sampleResults.reportCategories.length * 2, 'renders category gauges');
+    // fireworks!
+    assert.ok(output.querySelector('.score100'), 'has fireworks treatment');
+  });
+
   it('renders `not_applicable` audits as `notApplicable`', () => {
     const clonedSampleResult = JSON.parse(JSON.stringify(sampleResultsOrig));
 
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 3e7fc8677..09f5ae673 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -124,9 +124,9 @@
   "Viewport": "width=device-width, initial-scale=1, minimum-scale=1",
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   },
   "ThemeColor": null,
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 8774274e2..c0f11d7da 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -478,7 +478,7 @@ describe('Runner', () => {
 
     return Runner.run({}, {config}).then(results => {
       assert.strictEqual(results.artifacts.ViewportDimensions.innerWidth, 412);
-      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 732);
+      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 660);
     });
   });