Repository: GoogleChrome/lighthouse
Pull request #7354 (2019-03-01 00:20:41)

docs: improve docs around preset configuration and using the Node module
**Summary**
Added improvements to the docs around configuring Lighthouse. I recently found some aspects of the existing docs confusing or lack. See https://github.com/GoogleChrome/lighthouse/issues/7344 for discussion.

This PR makes the following docs changes:
- ~~Adds `lr-desktop` and `lr-mobile` to the list of choices available with the `--preset` CLI flag~~
- Adds warning about the `--preset` flag being ignored if the `--config-path` flag is provided
- Adds an example config file reference for the `--config-path`  CLI flag so user knows config format
- Adds a configuration section with example in a more prominent place in the additional docs readme
- Adds note about the limitations of the `extends` property and how to use presets programmatically
- Adds links to newer presets available in the configuration readme



Patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index d54eb3aeb..d6cf85be5 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -196,7 +196,11 @@ The groups property controls how to visually group audits within a category. For
 
 ## Config Extension
 
-The stock Lighthouse configurations can be extended if you only need to make small tweaks, such as adding an audit or skipping an audit, but wish to still run most of what Lighthouse offers. When adding the `extends: 'lighthouse:default'` property to your config, the default passes, audits, groups, and categories will be automatically included, allowing you modify settings or add additional audits to a pass. See [more examples below](#more-examples) to see different types of extension in action.
+The stock Lighthouse configurations can be extended if you only need to make small tweaks, such as adding an audit or skipping an audit, but wish to still run most of what Lighthouse offers. When adding the `extends: 'lighthouse:default'` property to your config, the default passes, audits, groups, and categories will be automatically included, allowing you modify settings or add additional audits to a pass.
+
+Please note that you can only extend from `lighthouse:default` or `lighthouse:full` using the `extends` property. Other internal configs found in the [lighthouse-core/config](https://github.com/GoogleChrome/lighthouse/tree/master/lighthouse-core/config) directory can be used by importing the config object from file reference, or by using the [`--preset`](https://github.com/GoogleChrome/lighthouse#cli-options) CLI flag.
+
+See [more examples below](#more-examples) to view different types of extensions in action.
 
 **Config extension is the recommended way to run custom Lighthouse**. If there's a use case that extension doesn't currently solve, we'd love to [hear from you](https://github.com/GoogleChrome/lighthouse/issues/new)!
 
@@ -205,7 +209,11 @@ The stock Lighthouse configurations can be extended if you only need to make sma
 The best examples are the ones Lighthouse uses itself! There are several reference configuration files that are maintained as part of Lighthouse.
 
 * [lighthouse-core/config/default-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/default-config.js)
+* [lighthouse-core/config/full-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/full-config.js)
+* [lighthouse-core/config/lr-desktop-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/lr-desktop-config.js)
+* [lighthouse-core/config/lr-mobile-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/lr-mobile-config.js)
 * [lighthouse-core/config/perf-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/perf-config.js)
+* [lighthouse-core/config/mixed-content-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/mixed-content-config.js)
 * [docs/recipes/custom-audit/custom-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/docs/recipes/custom-audit/custom-config.js)
 * [pwmetrics](https://github.com/paulirish/pwmetrics/blob/v4.1.1/lib/perf-config.ts)
 
diff --git a/docs/readme.md b/docs/readme.md
index 51cf8fbe8..531157b73 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -82,6 +82,29 @@ log.setLevel(flags.logLevel);
 launchChromeAndRunLighthouse('https://example.com', flags).then(...);
 ```
 
+## Configuration
+In order to extend the Lighthouse configuration programmatically, you need to pass the config object as the 3rd argument. If omitted, a default configuration is used.
+
+**Example:**
+```js
+{
+  extends: 'lighthouse:default',
+  settings: {
+    onlyAudits: [
+      'first-meaningful-paint',
+      'speed-index-metric',
+      'estimated-input-latency',
+      'first-interactive',
+      'consistently-interactive',
+    ],
+  },
+}
+```
+
+You can extend base configuration from either [lighthouse:default](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/default-config.js) or [lighthouse:full](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/full-config.js). Alternatively, you can build up your own configuration from scratch to have complete control.
+
+For more information on the types of config you can provide, see [Lighthouse Configuration](https://github.com/GoogleChrome/lighthouse/blob/master/docs/configuration.md).
+
 ## Testing on a site with authentication
 
 When installed globally via `npm i -g lighthouse` or `yarn global add lighthouse`,
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 6f9f0a9a6..a808cfd39 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -87,8 +87,10 @@ function getFlags(manualArgv) {
         'list-trace-categories': 'Prints a list of all required trace categories and exits',
         'additional-trace-categories':
             'Additional categories to capture with the trace (comma-delimited).',
-        'config-path': 'The path to the config JSON.',
-        'preset': 'Use a built-in configuration.',
+        'config-path': `The path to the config JSON. 
+            An example config file: lighthouse-core/config/lr-desktop-config.js`,
+        'preset': `Use a built-in configuration.
+            WARNING: If the --config-path flag is provided, this preset will be ignored.`,
         'chrome-flags':
             `Custom flags to pass to Chrome (space-delimited). For a full list of flags, see https://bit.ly/chrome-flags
             Additionally, use the CHROME_PATH environment variable to use a specific Chrome binary. Requires Chromium version 66.0 or later. If omitted, any detected Chrome Canary or Chrome stable will be used.`,
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index 3247b6e19..10cbf8f6e 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -62,53 +62,57 @@ function getDebuggableChrome(flags) {
   });
 }
 
+/** @return {never} */
 function showConnectionError() {
   console.error('Unable to connect to Chrome');
-  process.exit(_RUNTIME_ERROR_CODE);
+  return process.exit(_RUNTIME_ERROR_CODE);
 }
 
+/** @return {never} */
 function showProtocolTimeoutError() {
   console.error('Debugger protocol timed out while connecting to Chrome.');
-  process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
+  return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
 }
 
-/** @param {LH.LighthouseError} err */
+/** @param {LH.LighthouseError} err @return {never} */
 function showPageHungError(err) {
   console.error('Page hung:', err.friendlyMessage);
-  process.exit(_PAGE_HUNG_EXIT_CODE);
+  return process.exit(_PAGE_HUNG_EXIT_CODE);
 }
 
-/** @param {LH.LighthouseError} err */
+/** @param {LH.LighthouseError} err @return {never} */
 function showInsecureDocumentRequestError(err) {
   console.error('Insecure document request:', err.friendlyMessage);
-  process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
+  return process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
 }
 
 /**
  * @param {LH.LighthouseError} err
+ * @return {never}
  */
 function showRuntimeError(err) {
   console.error('Runtime error encountered:', err.friendlyMessage || err.message);
   if (err.stack) {
     console.error(err.stack);
   }
-  process.exit(_RUNTIME_ERROR_CODE);
+  return process.exit(_RUNTIME_ERROR_CODE);
 }
 
 /**
  * @param {LH.LighthouseError} err
+ * @return {never}
  */
 function handleError(err) {
   if (err.code === 'ECONNREFUSED') {
-    showConnectionError();
+    return showConnectionError();
   } else if (err.code === 'CRI_TIMEOUT') {
-    showProtocolTimeoutError();
+    return showProtocolTimeoutError();
   } else if (err.code === 'PAGE_HUNG') {
-    showPageHungError(err);
+    return showPageHungError(err);
   } else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
-    showInsecureDocumentRequestError(err);
+    return showInsecureDocumentRequestError(err);
   } else {
-    showRuntimeError(err);
+    return showRuntimeError(err);
   }
 }
 
@@ -159,66 +163,63 @@ async function saveResults(runnerResult, flags) {
   }
 }
 
+/**
+ * Attempt to kill the launched Chrome, if defined.
+ * @param {ChromeLauncher.LaunchedChrome=} launchedChrome
+ * @return {Promise<void>}
+ */
+async function potentiallyKillChrome(launchedChrome) {
+  if (!launchedChrome) return;
+
+  return Promise.race([
+    launchedChrome.kill(),
+    new Promise((_, reject) => setTimeout(reject, 5000, 'Timed out.')),
+  ]).catch(err => {
+    throw new Error(`Couldn't quit Chrome process. ${err}`);
+  });
+}
+
 /**
  * @param {string} url
  * @param {LH.CliFlags} flags
  * @param {LH.Config.Json|undefined} config
- * @return {Promise<LH.RunnerResult|void>}
+ * @return {Promise<LH.RunnerResult|undefined>}
  */
-function runLighthouse(url, flags, config) {
-  /** @type {ChromeLauncher.LaunchedChrome|undefined} */
-  let launchedChrome;
-  const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
-  let chromeP = Promise.resolve();
-
-  process.on('unhandledRejection', async (reason) => {
+async function runLighthouse(url, flags, config) {
+  /** @param {any} reason */
+  async function handleTheUnhandled(reason) {
     process.stderr.write(`Unhandled Rejection. Reason: ${reason}\n`);
-    try {
-      await potentiallyKillChrome();
-    } catch (err) {
-      process.stderr.write(`Couldn't quit Chrome process. ${err.toString()}\n`);
-    }
+    await potentiallyKillChrome(launchedChrome).catch(() => {});
     setTimeout(_ => {
       process.exit(1);
     }, 100);
-  });
-
-  if (shouldGather) {
-    chromeP = chromeP.then(_ =>
-      getDebuggableChrome(flags).then(launchedChromeInstance => {
-        launchedChrome = launchedChromeInstance;
-        flags.port = launchedChrome.port;
-      })
-    );
   }
+  process.on('unhandledRejection', handleTheUnhandled);
 
-  const resultsP = chromeP.then(_ => {
-    return lighthouse(url, flags, config).then(runnerResult => {
-      return potentiallyKillChrome().then(_ => runnerResult);
-    }).then(async runnerResult => {
-      // If in gatherMode only, there will be no runnerResult.
-      if (runnerResult) {
-        await saveResults(runnerResult, flags);
-      }
+  /** @type {ChromeLauncher.LaunchedChrome|undefined} */
+  let launchedChrome;
 
-      return runnerResult;
-    });
-  });
+  try {
+    const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
+    if (shouldGather) {
+      launchedChrome = await getDebuggableChrome(flags);
+      flags.port = launchedChrome.port;
+    }
 
-  return resultsP.catch(err => {
-    return Promise.resolve()
-      .then(_ => potentiallyKillChrome())
-      .then(_ => handleError(err));
-  });
+    const runnerResult = await lighthouse(url, flags, config);
 
-  /**
-   * @return {Promise<{}>}
-   */
-  function potentiallyKillChrome() {
-    if (launchedChrome !== undefined) {
-      return launchedChrome.kill();
+    // If in gatherMode only, there will be no runnerResult.
+    if (runnerResult) {
+      await saveResults(runnerResult, flags);
     }
-    return Promise.resolve({});
+
+    await potentiallyKillChrome(launchedChrome);
+    process.removeListener('unhandledRejection', handleTheUnhandled);
+
+    return runnerResult;
+  } catch (err) {
+    await potentiallyKillChrome(launchedChrome).catch(() => {});
+    handleError(err);
   }
 }
 
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index d48887056..d33826f2b 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -10,7 +10,7 @@
 const URL = require('../../lib/url-shim');
 const i18n = require('../../lib/i18n/i18n.js');
 const Audit = require('../audit');
-const ViewportAudit = require('../viewport');
+const ComputedViewportMeta = require('../../computed/viewport-meta.js');
 const MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT = 60;
 
 const UIStrings = {
@@ -213,11 +213,12 @@ class FontSize extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
-    if (!hasViewportSet) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+    if (!viewportMeta.isMobileOptimized) {
       return {
         rawValue: false,
         explanation: str_(UIStrings.explanationViewport),
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
index 08b0c387d..73c9fea96 100644
--- a/lighthouse-core/audits/seo/tap-targets.js
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -10,7 +10,7 @@
  * no other tap target that's too close so that the user might accidentally tap on.
  */
 const Audit = require('../audit');
-const ViewportAudit = require('../viewport');
+const ComputedViewportMeta = require('../../computed/viewport-meta.js');
 const {
   rectsTouchOrOverlap,
   getRectOverlapArea,
@@ -270,11 +270,12 @@ class TapTargets extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
-    if (!hasViewportSet) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+    if (!viewportMeta.isMobileOptimized) {
       return {
         rawValue: false,
         explanation: str_(UIStrings.explanationViewportMetaNotOptimized),
diff --git a/lighthouse-core/audits/viewport.js b/lighthouse-core/audits/viewport.js
index 90216c10f..29a59871f 100644
--- a/lighthouse-core/audits/viewport.js
+++ b/lighthouse-core/audits/viewport.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Audit = require('./audit');
-const Parser = require('metaviewport-parser');
+const ComputedViewportMeta = require('../computed/viewport-meta.js');
 
 class Viewport extends Audit {
   /**
@@ -26,33 +26,22 @@ class Viewport extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const viewportMeta = artifacts.MetaElements.find(meta => meta.name === 'viewport');
-    if (!viewportMeta) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+
+    if (!viewportMeta.hasViewportTag) {
       return {
-        explanation: 'No viewport meta tag found',
         rawValue: false,
+        explanation: 'No viewport meta tag found',
       };
     }
 
-    const warnings = [];
-    const parsedProps = Parser.parseMetaViewPortContent(viewportMeta.content || '');
-
-    if (Object.keys(parsedProps.unknownProperties).length) {
-      warnings.push(`Invalid properties found: ${JSON.stringify(parsedProps.unknownProperties)}`);
-    }
-    if (Object.keys(parsedProps.invalidValues).length) {
-      warnings.push(`Invalid values found: ${JSON.stringify(parsedProps.invalidValues)}`);
-    }
-
-    const viewportProps = parsedProps.validProperties;
-    const hasMobileViewport = viewportProps.width || viewportProps['initial-scale'];
-
     return {
-      rawValue: !!hasMobileViewport,
-      warnings,
+      rawValue: viewportMeta.isMobileOptimized,
+      warnings: viewportMeta.parserWarnings,
     };
   }
 }
diff --git a/lighthouse-core/computed/viewport-meta.js b/lighthouse-core/computed/viewport-meta.js
new file mode 100644
index 000000000..7bf2703aa
--- /dev/null
+++ b/lighthouse-core/computed/viewport-meta.js
@@ -0,0 +1,56 @@
+/**
+ * @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 Parser = require('metaviewport-parser');
+
+const makeComputedArtifact = require('./computed-artifact.js');
+
+class ViewportMeta {
+  /**
+   * @param {{MetaElements: LH.GathererArtifacts['MetaElements']}} artifacts
+   * @return {Promise<ViewportMetaResult>}
+  */
+  static async compute_({MetaElements}) {
+    const viewportMeta = MetaElements.find(meta => meta.name === 'viewport');
+
+    if (!viewportMeta) {
+      return {
+        hasViewportTag: false,
+        isMobileOptimized: false,
+        parserWarnings: [],
+      };
+    }
+
+    const warnings = [];
+    const parsedProps = Parser.parseMetaViewPortContent(viewportMeta.content || '');
+
+    if (Object.keys(parsedProps.unknownProperties).length) {
+      warnings.push(`Invalid properties found: ${JSON.stringify(parsedProps.unknownProperties)}`);
+    }
+    if (Object.keys(parsedProps.invalidValues).length) {
+      warnings.push(`Invalid values found: ${JSON.stringify(parsedProps.invalidValues)}`);
+    }
+
+    const viewportProps = parsedProps.validProperties;
+    const isMobileOptimized = Boolean(viewportProps.width || viewportProps['initial-scale']);
+
+    return {
+      hasViewportTag: true,
+      isMobileOptimized,
+      parserWarnings: warnings,
+    };
+  }
+}
+
+module.exports = makeComputedArtifact(ViewportMeta);
+
+/**
+ * @typedef {object} ViewportMetaResult
+ * @property {boolean} hasViewportTag Whether the page has any viewport tag.
+ * @property {boolean} isMobileOptimized Whether the viewport tag is optimized for mobile screens.
+ * @property {Array<string>} parserWarnings Warnings if the parser encountered invalid content in the viewport tag.
+ */
diff --git a/lighthouse-core/config/config-plugin.js b/lighthouse-core/config/config-plugin.js
index b5ec1000d..a6b83fa4d 100644
--- a/lighthouse-core/config/config-plugin.js
+++ b/lighthouse-core/config/config-plugin.js
@@ -84,7 +84,7 @@ class ConfigPlugin {
     }
 
     return auditRefsJson.map(auditRefJson => {
-      const {id, weight, ...invalidRest} = auditRefJson;
+      const {id, weight, group, ...invalidRest} = auditRefJson;
       assertNoExcessProperties(invalidRest, pluginName, 'auditRef');
 
       if (typeof id !== 'string') {
@@ -93,10 +93,15 @@ class ConfigPlugin {
       if (typeof weight !== 'number') {
         throw new Error(`${pluginName} has an invalid auditRef weight.`);
       }
+      if (typeof group !== 'string' && typeof group !== 'undefined') {
+        throw new Error(`${pluginName} has an invalid auditRef group.`);
+      }
 
+      const prependedGroup = group ? `${pluginName}-${group}` : group;
       return {
         id,
         weight,
+        group: prependedGroup,
       };
     });
   }
@@ -141,6 +146,47 @@ class ConfigPlugin {
     };
   }
 
+
+  /**
+   * Extract and validate groups JSON added by the plugin.
+   * @param {unknown} groupsJson
+   * @param {string} pluginName
+   * @return {Record<string, LH.Config.GroupJson>|undefined}
+   */
+  static _parseGroups(groupsJson, pluginName) {
+    if (groupsJson === undefined) {
+      return undefined;
+    }
+
+    if (!isObjectOfUnknownProperties(groupsJson)) {
+      throw new Error(`${pluginName} groups json is not defined as an object.`);
+    }
+
+    const groups = Object.entries(groupsJson);
+
+    /** @type {Record<string, LH.Config.GroupJson>} */
+    const parsedGroupsJson = {};
+    groups.forEach(([groupId, groupJson]) => {
+      if (!isObjectOfUnknownProperties(groupJson)) {
+        throw new Error(`${pluginName} has a group not defined as an object.`);
+      }
+      const {title, description, ...invalidRest} = groupJson;
+      assertNoExcessProperties(invalidRest, pluginName, 'group');
+
+      if (typeof title !== 'string') {
+        throw new Error(`${pluginName} has an invalid group title.`);
+      }
+      if (typeof description !== 'string' && typeof description !== 'undefined') {
+        throw new Error(`${pluginName} has an invalid group description.`);
+      }
+      parsedGroupsJson[`${pluginName}-${groupId}`] = {
+        title,
+        description,
+      };
+    });
+    return parsedGroupsJson;
+  }
+
   /**
    * Extracts and validates a ConfigJson from the provided plugin input, throwing
    * if it deviates from the expected object shape.
@@ -158,6 +204,7 @@ class ConfigPlugin {
     const {
       audits: pluginAuditsJson,
       category: pluginCategoryJson,
+      groups: pluginGroupsJson,
       ...invalidRest
     } = pluginJson;
 
@@ -168,6 +215,7 @@ class ConfigPlugin {
       categories: {
         [pluginName]: ConfigPlugin._parseCategory(pluginCategoryJson, pluginName),
       },
+      groups: ConfigPlugin._parseGroups(pluginGroupsJson, pluginName),
     };
   }
 }
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 54862b0e5..4df34ee9e 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3811,6 +3811,12 @@
                 "name": "lh:audit:viewport", 
                 "startTime": 0.0
             }, 
+            {
+                "duration": 100.0, 
+                "entryType": "measure", 
+                "name": "lh:computed:ViewportMeta", 
+                "startTime": 0.0
+            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
diff --git a/readme.md b/readme.md
index bb4aa2061..5a0e99c2c 100644
--- a/readme.md
+++ b/readme.md
@@ -56,6 +56,7 @@ Configuration:
   --print-config                 Print the normalized config for the given config and options, then exit.                                  [boolean]
   --additional-trace-categories  Additional categories to capture with the trace (comma-delimited).
   --config-path                  The path to the config JSON.
+                                 An example config file: lighthouse-core/config/lr-desktop-config.js
   --chrome-flags                 Custom flags to pass to Chrome (space-delimited). For a full list of flags, see
                                  http://peter.sh/experiments/chromium-command-line-switches/.
 
@@ -65,6 +66,7 @@ Configuration:
                                                                                                                                        [default: ""]
   --port                         The port to use for the debugging protocol. Use 0 for a random port                                    [default: 0]
   --preset                       Use a built-in configuration.                                            [choices: "full", "perf", "mixed-content"]
+                                 WARNING: If the --config-path flag is provided, this preset will be ignored.
   --hostname                     The hostname to use for the debugging protocol.                                              [default: "localhost"]
   --max-wait-for-load            The timeout (in milliseconds) to wait before the page is considered done loading and the run should continue.
                                  WARNING: Very high values can lead to large traces and instability                                 [default: 45000]
@@ -164,6 +166,12 @@ lighthouse -GA=./gmailartifacts https://gmail.com
 
 The first time you run the CLI you will be prompted with a message asking you if Lighthouse can anonymously report runtime exceptions. The Lighthouse team uses this information to detect new bugs and avoid regressions. Opting out will not affect your ability to use Lighthouse in any way. [Learn more](https://github.com/GoogleChrome/lighthouse/blob/master/docs/error-reporting.md).
 
+## Using the Node module
+You can also use Lighthouse programmatically with the Node module.
+
+Read [Using Lighthouse programmatically](./docs/readme.md#using-programmatically) for help getting started.\
+Read [Lighthouse Configuration](./docs/configuration.md) to learn more about the configuration options available.
+
 ## Viewing a report
 
 Lighthouse can produce a report as JSON or HTML.
diff --git a/types/config.d.ts b/types/config.d.ts
index 08c35958c..3ba80f0c7 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -113,7 +113,8 @@ declare global {
         audits?: Array<{path: string}>;
         /** Provide a category to display the plugin results in the report. */
         category: LH.Config.Category;
-        // TODO(bckenny): groups
+        /** Optionally provide more groups in addition to those specified by the base config. */
+        groups?: Record<string, LH.Config.GroupJson>;
       }
 
       export type MergeOptionsOfItems = <T extends {path?: string, options: Record<string, any>}>(items: T[]) => T[];


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
index 2156f2e9f..2c4cc4bdb 100644
--- a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
@@ -26,9 +26,13 @@ module.exports = [
         details: {
           overallSavingsBytes: '>45000',
           overallSavingsMs: '>500',
-          items: {
-            length: 1,
-          },
+          items: [
+            {
+              url: 'http://localhost:10200/byte-efficiency/script.js',
+              wastedBytes: '46481 +/- 100',
+              wastedPercent: '87 +/- 5',
+            },
+          ],
         },
       },
       'unused-css-rules': {
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index d7f5ff316..be6534d35 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -34,8 +34,12 @@ 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|\.)+)$/;
 const VERBOSE = Boolean(process.env.LH_SMOKE_VERBOSE);
+const NUMBER_REGEXP = /(?:\d|\.)+/.source;
+const OPS_REGEXP = /<=?|>=?|\+\/-/.source;
+// An optional number, optional whitespace, an operator, optional whitespace, a number.
+const NUMERICAL_EXPECTATION_REGEXP =
+  new RegExp(`^(${NUMBER_REGEXP})?\\s*(${OPS_REGEXP})\\s*(${NUMBER_REGEXP})$`);
 
 /**
  * Attempt to resolve a path locally. If this fails, attempts to locate the path
@@ -149,17 +153,18 @@ function runLighthouse(url, configPath, isDebug) {
 function matchesExpectation(actual, expected) {
   if (typeof actual === 'number' && NUMERICAL_EXPECTATION_REGEXP.test(expected)) {
     const parts = expected.match(NUMERICAL_EXPECTATION_REGEXP);
-    const operator = parts[1];
-    const number = parseFloat(parts[2]);
+    const [, prefixNumber, operator, postfixNumber] = parts;
     switch (operator) {
       case '>':
-        return actual > number;
+        return actual > postfixNumber;
       case '>=':
-        return actual >= number;
+        return actual >= postfixNumber;
       case '<':
-        return actual < number;
+        return actual < postfixNumber;
       case '<=':
-        return actual <= number;
+        return actual <= postfixNumber;
+      case '+/-':
+        return Math.abs(actual - prefixNumber) <= postfixNumber;
       default:
         throw new Error(`unexpected operator ${operator}`);
     }
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index ee6612d09..c075fe132 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -15,22 +15,23 @@ const validViewport = 'width=device-width';
 
 describe('SEO: Font size audit', () => {
   const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+  const getFakeContext = () => ({computedCache: new Map()});
 
-  it('fails when viewport is not set', () => {
+  it('fails when viewport is not set', async () => {
     const artifacts = {
       URL,
       MetaElements: [],
       FontSize: [],
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation)
       .toBeDisplayString('Text is illegible because there\'s ' +
         'no viewport meta tag optimized for mobile screens.');
   });
 
-  it('fails when less than 60% of text is legible', () => {
+  it('fails when less than 60% of text is legible', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -46,13 +47,13 @@ describe('SEO: Font size audit', () => {
       },
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation).toBeDisplayString('41% of text is too small.');
     expect(auditResult.displayValue).toBeDisplayString('59% legible text');
   });
 
-  it('passes when there is no text', () => {
+  it('passes when there is no text', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -67,11 +68,11 @@ describe('SEO: Font size audit', () => {
       },
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('passes when more than 60% of text is legible', () => {
+  it('passes when more than 60% of text is legible', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -86,12 +87,12 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, true);
     expect(auditResult.displayValue).toBeDisplayString('90% legible text');
   });
 
-  it('groups entries with same source, sorts them by coverage', () => {
+  it('groups entries with same source, sorts them by coverage', async () => {
     const style1 = {
       styleSheetId: 1,
       type: 'Regular',
@@ -123,7 +124,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
 
     assert.equal(auditResult.rawValue, false);
     assert.equal(auditResult.details.items.length, 2);
@@ -131,7 +132,7 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
-  it('adds a category for failing text that wasn\'t analyzed', () => {
+  it('adds a category for failing text that wasn\'t analyzed', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -145,7 +146,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[1].source, 'Add\'l illegible text');
@@ -153,7 +154,7 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
 
-  it('informs user if audit haven\'t covered all text on the page', () => {
+  it('informs user if audit haven\'t covered all text on the page', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -167,14 +168,14 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation)
       .toBeDisplayString('100% of text is too small (based on 50% sample).');
     expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
-  it('maintains 2 trailing decimal places', () => {
+  it('maintains 2 trailing decimal places', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -189,11 +190,11 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     expect(auditResult.displayValue).toBeDisplayString('89.78% legible text');
   });
 
-  it('maintains 2 trailing decimal places with only 1 leading digit', () => {
+  it('maintains 2 trailing decimal places with only 1 leading digit', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -208,7 +209,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     expect(auditResult.displayValue).toBeDisplayString('2.48% legible text');
   });
 });
diff --git a/lighthouse-core/test/audits/seo/tap-targets-test.js b/lighthouse-core/test/audits/seo/tap-targets-test.js
index 679e702bb..deb5fe5e8 100644
--- a/lighthouse-core/test/audits/seo/tap-targets-test.js
+++ b/lighthouse-core/test/audits/seo/tap-targets-test.js
@@ -10,6 +10,8 @@
 const TapTargetsAudit = require('../../../audits/seo/tap-targets.js');
 const assert = require('assert');
 
+const getFakeContext = () => ({computedCache: new Map()});
+
 function auditTapTargets(tapTargets, metaElements = [{
   name: 'viewport',
   content: 'width=device-width',
@@ -19,7 +21,7 @@ function auditTapTargets(tapTargets, metaElements = [{
     MetaElements: metaElements,
   };
 
-  return TapTargetsAudit.audit(artifacts);
+  return TapTargetsAudit.audit(artifacts, getFakeContext());
 }
 
 const tapTargetSize = 10;
@@ -113,27 +115,27 @@ function getBorderlineTapTargets(options = {}) {
 }
 
 describe('SEO: Tap targets audit', () => {
-  it('passes when there are no tap targets', () => {
-    const auditResult = auditTapTargets([]);
+  it('passes when there are no tap targets', async () => {
+    const auditResult = await auditTapTargets([]);
     assert.equal(auditResult.rawValue, true);
     expect(auditResult.displayValue).toBeDisplayString('100% appropriately sized tap targets');
     assert.equal(auditResult.score, 1);
   });
 
-  it('passes when tap targets don\'t overlap', () => {
-    const auditResult = auditTapTargets(getBorderlineTapTargets());
+  it('passes when tap targets don\'t overlap', async () => {
+    const auditResult = await auditTapTargets(getBorderlineTapTargets());
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('passes when a target is fully contained in an overlapping target', () => {
-    const auditResult = auditTapTargets(getBorderlineTapTargets({
+  it('passes when a target is fully contained in an overlapping target', async () => {
+    const auditResult = await auditTapTargets(getBorderlineTapTargets({
       addFullyContainedTapTarget: true,
     }));
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('fails if two tap targets overlaps each other horizontally', () => {
-    const auditResult = auditTapTargets(
+  it('fails if two tap targets overlaps each other horizontally', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
       })
@@ -152,8 +154,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failure.height, 10);
   });
 
-  it('fails if a tap target overlaps vertically', () => {
-    const auditResult = auditTapTargets(
+  it('fails if a tap target overlaps vertically', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapBelow: true,
       })
@@ -161,8 +163,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(auditResult.rawValue, false);
   });
 
-  it('fails when one of the client rects overlaps', () => {
-    const auditResult = auditTapTargets(
+  it('fails when one of the client rects overlaps', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapSecondClientRect: true,
       })
@@ -170,9 +172,9 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(auditResult.rawValue, false);
   });
 
-  it('reports 2 items if the main target is overlapped both vertically and horizontally', () => {
+  it('reports 2 items if a target overlapped both vertically and horizontally', async () => {
     // Main is overlapped by right + below, right and below are each overlapped by main
-    const auditResult = auditTapTargets(
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
         reduceRightWidth: true,
@@ -187,8 +189,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failures[0].tapTarget.snippet, '<right></right>');
   });
 
-  it('reports 1 failure if only one tap target involved in an overlap fails', () => {
-    const auditResult = auditTapTargets(
+  it('reports 1 failure if only one tap target involved in an overlap fails', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
         increaseRightWidth: true,
@@ -200,8 +202,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failures[0].tapTarget.snippet, '<main></main>');
   });
 
-  it('fails if no meta viewport tag is provided', () => {
-    const auditResult = auditTapTargets([], []);
+  it('fails if no meta viewport tag is provided', async () => {
+    const auditResult = await auditTapTargets([], []);
     assert.equal(auditResult.rawValue, false);
 
     expect(auditResult.explanation).toBeDisplayString(
diff --git a/lighthouse-core/test/audits/viewport-test.js b/lighthouse-core/test/audits/viewport-test.js
index 8756eb491..ec63c472e 100644
--- a/lighthouse-core/test/audits/viewport-test.js
+++ b/lighthouse-core/test/audits/viewport-test.js
@@ -12,68 +12,28 @@ const assert = require('assert');
 
 describe('Mobile-friendly: viewport audit', () => {
   const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+  const fakeContext = {computedCache: new Map()};
 
-  it('fails when HTML does not contain a viewport meta tag', () => {
-    return assert.equal(Audit.audit({
+  it('fails when HTML does not contain a viewport meta tag', async () => {
+    const auditResult = await Audit.audit({
       MetaElements: [],
-    }).rawValue, false);
+    }, fakeContext);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.explanation, 'No viewport meta tag found');
   });
 
-  it('fails when HTML contains a non-mobile friendly viewport meta tag', () => {
+  it('fails when HTML contains a non-mobile friendly viewport meta tag', async () => {
     const viewport = 'maximum-scale=1';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
-      MetaElements: makeMetaElements(viewport),
-    }).warnings[0], undefined);
+    const auditResult = await Audit.audit({MetaElements: makeMetaElements(viewport)}, fakeContext);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.warnings[0], undefined);
   });
 
-  it('fails when HTML contains an invalid viewport meta tag key', () => {
-    const viewport = 'nonsense=true';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
+  it('passes when a valid viewport is provided', async () => {
+    const viewport = 'initial-scale=1';
+    const auditResult = await Audit.audit({
       MetaElements: makeMetaElements(viewport),
-    }).warnings[0], 'Invalid properties found: {"nonsense":"true"}');
-  });
-
-  it('fails when HTML contains an invalid viewport meta tag value', () => {
-    const viewport = 'initial-scale=microscopic';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
-      MetaElements: makeMetaElements(viewport),
-    }).warnings[0], 'Invalid values found: {"initial-scale":"microscopic"}');
-  });
-
-  it('fails when HTML contains an invalid viewport meta tag key and value', () => {
-    const viewport = 'nonsense=true, initial-scale=microscopic';
-    const {rawValue, warnings} = Audit.audit({MetaElements: makeMetaElements(viewport)});
-    assert.equal(rawValue, false);
-    assert.equal(warnings[0], 'Invalid properties found: {"nonsense":"true"}');
-    assert.equal(warnings[1], 'Invalid values found: {"initial-scale":"microscopic"}');
-  });
-
-  it('passes when a valid viewport is provided', () => {
-    const viewports = [
-      'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1',
-      'width = device-width, initial-scale = 1',
-      'initial-scale=1',
-      'width=device-width     ',
-    ];
-    viewports.forEach(viewport => {
-      assert.equal(Audit.audit({
-        MetaElements: makeMetaElements(viewport),
-      }).rawValue, true);
-    });
-  });
-
-  it('doesn\'t throw when viewport contains "invalid" iOS properties', () => {
-    const viewports = [
-      'width=device-width, shrink-to-fit=no',
-      'width=device-width, viewport-fit=cover',
-    ];
-    viewports.forEach(viewport => {
-      const result = Audit.audit({MetaElements: makeMetaElements(viewport)});
-      assert.equal(result.rawValue, true);
-      assert.equal(result.warnings[0], undefined);
-    });
+    }, fakeContext);
+    assert.equal(auditResult.rawValue, true);
   });
 });
diff --git a/lighthouse-core/test/computed/viewport-meta-test.js b/lighthouse-core/test/computed/viewport-meta-test.js
new file mode 100644
index 000000000..62b89c7ec
--- /dev/null
+++ b/lighthouse-core/test/computed/viewport-meta-test.js
@@ -0,0 +1,85 @@
+/**
+ * @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';
+
+/* eslint-env jest */
+
+const assert = require('assert');
+const ViewportMeta = require('../../computed/viewport-meta');
+
+describe('ViewportMeta computed artifact', () => {
+  const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+
+  it('is not mobile optimized when page does not contain a viewport meta tag', async () => {
+    const {hasViewportTag, isMobileOptimized} = await ViewportMeta.compute_({MetaElements: []});
+    assert.equal(hasViewportTag, false);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  /* eslint-disable-next-line max-len */
+  it('is not mobile optimized when HTML contains a non-mobile friendly viewport meta tag', async () => {
+    const viewport = 'maximum-scale=1';
+    const {hasViewportTag, isMobileOptimized} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(hasViewportTag, true);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag key', async () => {
+    const viewport = 'nonsense=true';
+    const {hasViewportTag, isMobileOptimized} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(hasViewportTag, true);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag value', async () => {
+    const viewport = 'initial-scale=microscopic';
+    const {isMobileOptimized, parserWarnings} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(isMobileOptimized, false);
+    assert.equal(parserWarnings[0], 'Invalid values found: {"initial-scale":"microscopic"}');
+  });
+
+  /* eslint-disable-next-line max-len */
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag key and value', async () => {
+    const viewport = 'nonsense=true, initial-scale=microscopic';
+    const {isMobileOptimized, parserWarnings} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(isMobileOptimized, false);
+    assert.equal(parserWarnings[0], 'Invalid properties found: {"nonsense":"true"}');
+    assert.equal(parserWarnings[1], 'Invalid values found: {"initial-scale":"microscopic"}');
+  });
+
+  it('is mobile optimized when a valid viewport is provided', async () => {
+    const viewports = [
+      'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1',
+      'width = device-width, initial-scale = 1',
+      'initial-scale=1',
+      'width=device-width     ',
+    ];
+
+    await Promise.all(viewports.map(async viewport => {
+      const {isMobileOptimized} =
+        await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+      assert.equal(isMobileOptimized, true);
+    }));
+  });
+
+  it('doesn\'t throw when viewport contains "invalid" iOS properties', async () => {
+    const viewports = [
+      'width=device-width, shrink-to-fit=no',
+      'width=device-width, viewport-fit=cover',
+    ];
+    await Promise.all(viewports.map(async viewport => {
+      const {isMobileOptimized, parserWarnings} =
+        await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+      assert.equal(isMobileOptimized, true);
+      assert.equal(parserWarnings[0], undefined);
+    }));
+  });
+});
+
diff --git a/lighthouse-core/test/config/__snapshots__/config-plugin-test.js.snap b/lighthouse-core/test/config/__snapshots__/config-plugin-test.js.snap
index 4318e5c17..1bcaf13dd 100644
--- a/lighthouse-core/test/config/__snapshots__/config-plugin-test.js.snap
+++ b/lighthouse-core/test/config/__snapshots__/config-plugin-test.js.snap
@@ -11,10 +11,12 @@ Object {
     "lighthouse-plugin-nice-plugin": Object {
       "auditRefs": Array [
         Object {
+          "group": "lighthouse-plugin-nice-plugin-group-a",
           "id": "nice-audit",
           "weight": 1,
         },
         Object {
+          "group": undefined,
           "id": "installable-manifest",
           "weight": 220,
         },
@@ -24,5 +26,15 @@ Object {
       "title": "Nice Plugin",
     },
   },
+  "groups": Object {
+    "lighthouse-plugin-nice-plugin-group-a": Object {
+      "description": undefined,
+      "title": "Group A",
+    },
+    "lighthouse-plugin-nice-plugin-group-b": Object {
+      "description": "This description is optional",
+      "title": "Group B",
+    },
+  },
 }
 `;
diff --git a/lighthouse-core/test/config/config-plugin-test.js b/lighthouse-core/test/config/config-plugin-test.js
index 9308235bb..23e8dcdfb 100644
--- a/lighthouse-core/test/config/config-plugin-test.js
+++ b/lighthouse-core/test/config/config-plugin-test.js
@@ -21,11 +21,20 @@ function deepClone(val) {
 const nicePluginName = 'lighthouse-plugin-nice-plugin';
 const nicePlugin = {
   audits: [{path: 'not/a/path/audit.js'}],
+  groups: {
+    'group-a': {
+      title: 'Group A',
+    },
+    'group-b': {
+      title: 'Group B',
+      description: 'This description is optional',
+    },
+  },
   category: {
     title: 'Nice Plugin',
     description: 'A nice plugin for nice testing',
     auditRefs: [
-      {id: 'nice-audit', weight: 1},
+      {id: 'nice-audit', weight: 1, group: 'group-a'},
       {id: 'installable-manifest', weight: 220},
     ],
   },
@@ -62,7 +71,7 @@ describe('ConfigPlugin', () => {
       description: 'A plugin that\'s trying to undermine you.',
       manualDescription: 'Still here.',
       auditRefs: [
-        {id: 'evil-audit', weight: 0},
+        {id: 'evil-audit', weight: 0, group: undefined},
       ],
     };
 
@@ -84,6 +93,7 @@ describe('ConfigPlugin', () => {
       categories: {
         'lighthouse-plugin-evil': evilCategory,
       },
+      groups: undefined,
     });
     assert.strictEqual(Object.getOwnPropertyDescriptor(pluginJson, 'audits').get, undefined);
   });
@@ -202,8 +212,10 @@ describe('ConfigPlugin', () => {
         const pluginJson = ConfigPlugin.parsePlugin(nicePlugin, nicePluginName);
 
         const auditRefs = pluginJson.categories[nicePluginName].auditRefs;
-        assert.deepStrictEqual(auditRefs[0], {id: 'nice-audit', weight: 1});
-        assert.deepStrictEqual(auditRefs[1], {id: 'installable-manifest', weight: 220});
+        assert.deepStrictEqual(auditRefs[0],
+          {id: 'nice-audit', weight: 1, group: 'lighthouse-plugin-nice-plugin-group-a'});
+        assert.deepStrictEqual(auditRefs[1],
+          {id: 'installable-manifest', weight: 220, group: undefined});
       });
 
       it('throws if auditRefs is missing', () => {
@@ -251,6 +263,66 @@ describe('ConfigPlugin', () => {
         assert.throws(() => ConfigPlugin.parsePlugin(pluginClone2, nicePluginName),
           /^Error: lighthouse-plugin-nice-plugin has an invalid auditRef weight/);
       });
+
+      it('throws if auditRef has an invalid group id', () => {
+        const pluginClone = deepClone(nicePlugin);
+        pluginClone.category.auditRefs[0].group = 55;
+        assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+          /^Error: lighthouse-plugin-nice-plugin has an invalid auditRef group/);
+      });
+    });
+  });
+
+  describe('`groups`', () => {
+    it('accepts a plugin with no groups', () => {
+      const pluginClone = deepClone(nicePlugin);
+      delete pluginClone.groups;
+      const pluginJson = ConfigPlugin.parsePlugin(pluginClone, nicePluginName);
+      assert.ok(pluginJson);
+    });
+
+    it('throws if groups is not an object', () => {
+      const pluginClone = deepClone(nicePlugin);
+      pluginClone.groups = [0, 1, 2, 3];
+      assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+        /^Error: lighthouse-plugin-nice-plugin groups json is not defined as an object/);
+    });
+
+    it('throws if groups contains non-objects', () => {
+      const pluginClone = deepClone(nicePlugin);
+      pluginClone.groups['group-b'] = [0, 1, 2, 3];
+      assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+        /^Error: lighthouse-plugin-nice-plugin has a group not defined as an object/);
+    });
+
+    it('throws if group title is invalid', () => {
+      const pluginClone = deepClone(nicePlugin);
+      pluginClone.groups['group-a'].title = 55;
+      assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+        /^Error: lighthouse-plugin-nice-plugin has an invalid group title/);
+    });
+
+    it('throws if group description is invalid', () => {
+      const pluginClone = deepClone(nicePlugin);
+      pluginClone.groups['group-a'].description = 55;
+      assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+        /^Error: lighthouse-plugin-nice-plugin has an invalid group description/);
+    });
+
+    it('throws if it contains groups with excess properties', () => {
+      const pluginClone = deepClone(nicePlugin);
+      pluginClone.groups['group-a'].city = 'Paris';
+      assert.throws(() => ConfigPlugin.parsePlugin(pluginClone, nicePluginName),
+        /^Error: lighthouse-plugin-nice-plugin has unrecognized group properties:.*city.*/);
+    });
+
+    it('correctly passes through the contained groups', () => {
+      const pluginJson = ConfigPlugin.parsePlugin(nicePlugin, nicePluginName);
+      const groups = pluginJson.groups;
+      assert.deepStrictEqual(groups,
+        {'lighthouse-plugin-nice-plugin-group-a': {title: 'Group A', description: undefined},
+          'lighthouse-plugin-nice-plugin-group-b':
+          {title: 'Group B', description: 'This description is optional'}});
     });
   });
 });
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index d507179f1..5ba657feb 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -699,14 +699,20 @@ describe('Config', () => {
   describe('mergePlugins', () => {
     const configFixturePath = __dirname + '/../fixtures/config-plugins/';
 
-    it('should append audits', () => {
+    it('should append audits and a group', () => {
       const configJson = {
         audits: ['installable-manifest', 'metrics'],
         plugins: ['lighthouse-plugin-simple'],
       };
       const config = new Config(configJson, {configPath: configFixturePath});
+      const groupIds = Object.keys(config.groups);
       assert.deepStrictEqual(config.audits.map(a => a.path),
         ['installable-manifest', 'metrics', 'redirects', 'user-timings']);
+      assert.ok(groupIds.length === 1);
+      assert.strictEqual(groupIds[groupIds.length - 1], 'lighthouse-plugin-simple-new-group');
+      assert.strictEqual(config.groups['lighthouse-plugin-simple-new-group'].title, 'New Group');
+      assert.strictEqual(config.categories['lighthouse-plugin-simple'].auditRefs[0].group,
+        'lighthouse-plugin-simple-new-group');
     });
 
     it('should append a category', () => {
diff --git a/lighthouse-core/test/fixtures/config-plugins/lighthouse-plugin-simple/plugin-simple.js b/lighthouse-core/test/fixtures/config-plugins/lighthouse-plugin-simple/plugin-simple.js
index ebf2b818d..0e7c9d726 100644
--- a/lighthouse-core/test/fixtures/config-plugins/lighthouse-plugin-simple/plugin-simple.js
+++ b/lighthouse-core/test/fixtures/config-plugins/lighthouse-plugin-simple/plugin-simple.js
@@ -7,12 +7,19 @@
 
 /** @type {LH.Config.Plugin} */
 module.exports = {
+  groups: {
+    'new-group': {
+      title: 'New Group',
+    },
+  },
   audits: [
     {path: 'redirects'},
     {path: 'user-timings'},
   ],
   category: {
     title: 'Simple',
-    auditRefs: [],
+    auditRefs: [
+      {id: 'redirects', weight: 1, group: 'new-group'},
+    ],
   },
 };
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 7a8e7e8a2..fb0b83114 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -3955,6 +3955,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:computed:ViewportMeta",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:without-javascript",