Repository: GoogleChrome/lighthouse
Pull request #5345 (2018-05-24 19:10:50)

docs(releasing): minor updates
_update_: see https://github.com/GoogleChrome/lighthouse/pull/5345#issuecomment-424825309

brendankenny: wait, it's still not ignored? https://github.com/GoogleChrome/lighthouse/blob/520f9ac06420634ccc03d0326e8e42fe83610a42/.npmignore#L23
paulirish: @brendankenny blast from the past. ;)

##### changes

* lighthouse-core/scripts wasn't ignored correctly. their syntax is odd if you want most files in a dir ignored but one allowed. this works though.
* keep all of lighthouse-cli/tests. most notably all the fixtures to support smokehouse
* ignore latest-run
* remove mentions of `yarn smokehouse` since [that died here](https://github.com/GoogleChrome/lighthouse/commit/419b023680a51080d078b0c782ebae23b34fefdc).
* remove the ["drive-by-tightening"](https://github.com/GoogleChrome/lighthouse/pull/5025/files#r183588493) that doesnt seem to help and actually prevents the `npm explore lighthouse -- npm run smoke` from working. 

Patch:
diff --git a/.npmignore b/.npmignore
index 30a2390d8..6c3e65d98 100644
--- a/.npmignore
+++ b/.npmignore
@@ -7,7 +7,7 @@ assets/
 build/
 coverage/
 
-lighthouse-core/scripts/
+lighthouse-core/scripts/*
 lighthouse-core/test/
 lighthouse-core/third_party/src/
 lighthouse-viewer/
@@ -18,21 +18,13 @@ lighthouse-logger/
 # keep the chrome launcher script
 !lighthouse-core/scripts/manual-chrome-launcher.js
 
-# excluding cli/test minus smokehouse
-lighthouse-cli/test
-!lighthouse-cli/test/smokehouse
-!lighthouse-cli/test/fixtures/
-
-!lighthouse-cli/index.js
-lighthouse-cli/*.map
-lighthouse-cli/commands/*.map
-lighthouse-cli/types/*.js
-lighthouse-cli/types/*.map
+# keep smokehouse tests, etc
+!lighthouse-cli/test
 
 node_modules/
 results/
 lantern-data/
-
+latest-run/
 plots/
 
 # generated files
@@ -45,6 +37,7 @@ closure-error.log
 last-run-results.html
 latest.artifacts.log
 npm-debug.log
+yarn-error.log
 results.html
 
 # dev files
diff --git a/docs/releasing.md b/docs/releasing.md
index 9839b1df3..5bad45e41 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -54,17 +54,16 @@ echo "Test the extension"
 
 echo "Test a fresh local install"
 # (starting from lighthouse-pristine root...)
-yarn pack
+npm pack
 cd ..; rm -rf tmp; mkdir tmp; cd tmp
 npm init -y
 npm install ../lighthouse-pristine/lighthouse-*.tgz
 npm explore lighthouse -- npm run smoke
-npm explore lighthouse -- npm run smokehouse
 npm explore lighthouse -- npm run chrome # try the manual launcher
 npm explore lighthouse -- npm run fast -- http://example.com
 cd ..; rm -rf ./tmp;
 
-cd lighthouse-pristine; command rm -f lighthouse-*.tgz
+cd ../lighthouse-pristine; command rm -f lighthouse-*.tgz
 
 echo "Test the lighthouse-viewer build"
 # Manual test for now:
@@ -100,8 +99,8 @@ echo "Go here: https://chrome.google.com/webstore/developer/edit/blipmdconlkpine
 echo "Upload the package zip to CWS dev dashboard"
 
 echo "Verify the npm package won't include unncessary files"
-yarn global add pkgfiles
-pkgfiles   # publishable size should be ~2MB
+npm pack --dry-run
+npx pkgfiles
 
 echo "ship it"
 npm publish
diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js
index d02844d89..1cafc139c 100644
--- a/lighthouse-cli/bin.js
+++ b/lighthouse-cli/bin.js
@@ -12,6 +12,7 @@ const commands = require('./commands/commands.js');
 const printer = require('./printer.js');
 const getFlags = require('./cli-flags.js').getFlags;
 const runLighthouse = require('./run').runLighthouse;
+const generateConfig = require('../lighthouse-core/index.js').generateConfig;
 
 const log = require('lighthouse-logger');
 const pkg = require('../package.json');
@@ -45,18 +46,18 @@ if (cliFlags.listTraceCategories) {
 const url = cliFlags._[0];
 
 /** @type {LH.Config.Json|undefined} */
-let config;
+let configJson;
 if (cliFlags.configPath) {
   // Resolve the config file path relative to where cli was called.
   cliFlags.configPath = path.resolve(process.cwd(), cliFlags.configPath);
-  config = /** @type {LH.Config.Json} */ (require(cliFlags.configPath));
+  configJson = /** @type {LH.Config.Json} */ (require(cliFlags.configPath));
 } else if (cliFlags.preset) {
   if (cliFlags.preset === 'mixed-content') {
     // The mixed-content audits require headless Chrome (https://crbug.com/764505).
     cliFlags.chromeFlags = `${cliFlags.chromeFlags} --headless`;
   }
 
-  config = require(`../lighthouse-core/config/${cliFlags.preset}-config.js`);
+  configJson = require(`../lighthouse-core/config/${cliFlags.preset}-config.js`);
 }
 
 // set logging preferences
@@ -94,34 +95,35 @@ if (cliFlags.extraHeaders) {
 /**
  * @return {Promise<LH.RunnerResult|void>}
  */
-function run() {
-  return Promise.resolve()
-    .then(_ => {
-      // By default, cliFlags.enableErrorReporting is undefined so the user is
-      // prompted. This can be overriden with an explicit flag or by the cached
-      // answer returned by askPermission().
-      if (typeof cliFlags.enableErrorReporting === 'undefined') {
-        return askPermission().then(answer => {
-          cliFlags.enableErrorReporting = answer;
-        });
-      }
-    })
-    .then(_ => {
-      Sentry.init({
-        url,
-        flags: cliFlags,
-        environmentData: {
-          name: 'redacted', // prevent sentry from using hostname
-          environment: isDev() ? 'development' : 'production',
-          release: pkg.version,
-          tags: {
-            channel: 'cli',
-          },
-        },
-      });
+async function run() {
+  if (cliFlags.printConfig) {
+    const config = generateConfig(configJson, cliFlags);
+    process.stdout.write(config.getPrintString());
+    return;
+  }
 
-      return runLighthouse(url, cliFlags, config);
+  // By default, cliFlags.enableErrorReporting is undefined so the user is
+  // prompted. This can be overriden with an explicit flag or by the cached
+  // answer returned by askPermission().
+  if (typeof cliFlags.enableErrorReporting === 'undefined') {
+    cliFlags.enableErrorReporting = await askPermission();
+  }
+  if (cliFlags.enableErrorReporting) {
+    Sentry.init({
+      url,
+      flags: cliFlags,
+      environmentData: {
+        name: 'redacted', // prevent sentry from using hostname
+        environment: isDev() ? 'development' : 'production',
+        release: pkg.version,
+        tags: {
+          channel: 'cli',
+        },
+      },
     });
+  }
+
+  return runLighthouse(url, cliFlags, configJson);
 }
 
 module.exports = {
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 90c46dad1..f8a094fe0 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -56,7 +56,7 @@ function getFlags(manualArgv) {
 
       .group(
         [
-          'save-assets', 'list-all-audits', 'list-trace-categories', 'additional-trace-categories',
+          'save-assets', 'list-all-audits', 'list-trace-categories', 'print-config', 'additional-trace-categories',
           'config-path', 'preset', 'chrome-flags', 'port', 'hostname', 'emulated-form-factor',
           'max-wait-for-load', 'enable-error-reporting', 'gather-mode', 'audit-mode',
           'only-audits', 'only-categories', 'skip-audits',
@@ -100,6 +100,7 @@ function getFlags(manualArgv) {
         'only-audits': 'Only run the specified audits',
         'only-categories': 'Only run the specified categories',
         'skip-audits': 'Run everything except these audits',
+        'print-config': 'Print the normalized config for the given config and options, then exit.',
       })
       // set aliases
       .alias({'gather-mode': 'G', 'audit-mode': 'A'})
@@ -118,7 +119,7 @@ function getFlags(manualArgv) {
       // boolean values
       .boolean([
         'disable-storage-reset', 'disable-device-emulation', 'save-assets', 'list-all-audits',
-        'list-trace-categories', 'view', 'verbose', 'quiet', 'help',
+        'list-trace-categories', 'view', 'verbose', 'quiet', 'help', 'print-config',
       ])
       .choices('output', printer.getValidOutputOptions())
       .choices('emulated-form-factor', ['mobile', 'desktop', 'none'])
@@ -142,16 +143,19 @@ function getFlags(manualArgv) {
       .default('enable-error-reporting', undefined) // Undefined so prompted by default
       .check(/** @param {LH.CliFlags} argv */ (argv) => {
         // Lighthouse doesn't need a URL if...
-        //   - We're in auditMode (and we have artifacts already)
         //   - We're just listing the available options.
-        // If one of these don't apply, stop the program and ask for a url.
-        const isListMode = argv.listAllAudits || argv.listTraceCategories;
+        //   - We're just printing the config.
+        //   - We're in auditMode (and we have artifacts already)
+        // If one of these don't apply, if no URL, stop the program and ask for one.
+        const isPrintSomethingMode = argv.listAllAudits || argv.listTraceCategories || argv.printConfig;
         const isOnlyAuditMode = !!argv.auditMode && !argv.gatherMode;
-        if (!isListMode && !isOnlyAuditMode && argv._.length === 0) {
-          throw new Error('Please provide a url');
+        if (isPrintSomethingMode || isOnlyAuditMode) {
+          return true;
+        } else if (argv._.length > 0) {
+          return true;
         }
 
-        return true;
+        throw new Error('Please provide a url');
       })
       .epilogue(
           'For more information on Lighthouse, see https://developers.google.com/web/tools/lighthouse/.')
diff --git a/lighthouse-core/audits/content-width.js b/lighthouse-core/audits/content-width.js
index 334206f71..7de0b62fb 100644
--- a/lighthouse-core/audits/content-width.js
+++ b/lighthouse-core/audits/content-width.js
@@ -19,23 +19,37 @@ class ContentWidth extends Audit {
       description: 'If the width of your app\'s content doesn\'t match the width ' +
           'of the viewport, your app might not be optimized for mobile screens. ' +
           '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/content-sized-correctly-for-viewport).',
-      requiredArtifacts: ['ViewportDimensions'],
+      requiredArtifacts: ['ViewportDimensions', 'HostUserAgent'],
     };
   }
 
   /**
    * @param {LH.Artifacts} artifacts
+   * @param {LH.Audit.Context} context
    * @return {LH.Audit.Product}
    */
-  static audit(artifacts) {
+  static audit(artifacts, context) {
+    const userAgent = artifacts.HostUserAgent;
     const viewportWidth = artifacts.ViewportDimensions.innerWidth;
     const windowWidth = artifacts.ViewportDimensions.outerWidth;
     const widthsMatch = viewportWidth === windowWidth;
 
-    return {
-      rawValue: widthsMatch,
-      explanation: this.createExplanation(widthsMatch, artifacts.ViewportDimensions),
-    };
+    // TODO(phulce): refactor this `isMobile` boolean to be on context
+    const isMobileHost = userAgent.includes('Android') || userAgent.includes('Mobile');
+    const isMobile = context.settings.emulatedFormFactor === 'mobile' ||
+      (context.settings.emulatedFormFactor !== 'desktop' && isMobileHost);
+
+    if (isMobile) {
+      return {
+        rawValue: widthsMatch,
+        explanation: this.createExplanation(widthsMatch, artifacts.ViewportDimensions),
+      };
+    } else {
+      return {
+        rawValue: true,
+        notApplicable: true,
+      };
+    }
   }
 
   /**
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index e0f6ceb80..aa5847b1b 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -280,6 +280,9 @@ function deepCloneConfigJson(json) {
 }
 
 /**
+ * If any items with identical `path` properties are found in the input array,
+ * merge their `options` properties into the first instance and then discard any
+ * other instances.
  * Until support of jsdoc templates with constraints, type in config.d.ts.
  * See https://github.com/Microsoft/TypeScript/issues/24283
  * @type {LH.Config.MergeOptionsOfItems}
@@ -363,6 +366,45 @@ class Config {
     const configJson = this; // eslint-disable-line no-unused-vars
   }
 
+  /**
+   * Provides a cleaned-up, stringified version of this config. Gatherer and
+   * Audit `implementation` and `instance` do not survive this process.
+   * @return {string}
+   */
+  getPrintString() {
+    const jsonConfig = deepClone(this);
+
+    if (jsonConfig.passes) {
+      for (const pass of jsonConfig.passes) {
+        for (const gathererDefn of pass.gatherers) {
+          gathererDefn.implementation = undefined;
+          // @ts-ignore Breaking the Config.GathererDefn type.
+          gathererDefn.instance = undefined;
+          if (Object.keys(gathererDefn.options).length === 0) {
+            // @ts-ignore Breaking the Config.GathererDefn type.
+            gathererDefn.options = undefined;
+          }
+        }
+      }
+    }
+
+    if (jsonConfig.audits) {
+      for (const auditDefn of jsonConfig.audits) {
+        // @ts-ignore Breaking the Config.AuditDefn type.
+        auditDefn.implementation = undefined;
+        if (Object.keys(auditDefn.options).length === 0) {
+          // @ts-ignore Breaking the Config.AuditDefn type.
+          auditDefn.options = undefined;
+        }
+      }
+    }
+
+    // Printed config is more useful with localized strings.
+    i18n.replaceIcuMessageInstanceIds(jsonConfig, jsonConfig.settings.locale);
+
+    return JSON.stringify(jsonConfig, null, 2);
+  }
+
   /**
    * @param {LH.Config.Json} baseJSON The JSON of the configuration to extend
    * @param {LH.Config.Json} extendJSON The JSON of the extensions
diff --git a/lighthouse-core/index.js b/lighthouse-core/index.js
index da7b8b183..effe0545a 100644
--- a/lighthouse-core/index.js
+++ b/lighthouse-core/index.js
@@ -48,8 +48,7 @@ async function lighthouse(url, flags = {}, configJSON, connection) {
   flags.logLevel = flags.logLevel || 'error';
   log.setLevel(flags.logLevel);
 
-  // Use ConfigParser to generate a valid config file
-  const config = new Config(configJSON, flags);
+  const config = generateConfig(configJSON, flags);
 
   connection = connection || new ChromeProtocol(flags.port, flags.hostname);
 
@@ -57,6 +56,19 @@ async function lighthouse(url, flags = {}, configJSON, connection) {
   return Runner.run(connection, {url, config});
 }
 
+/**
+ * Generate a Lighthouse Config.
+ * @param {LH.Config.Json=} configJson Configuration for the Lighthouse run. If
+ *   not present, the default config is used.
+ * @param {LH.Flags=} flags Optional settings for the Lighthouse run. If present,
+ *   they will override any settings in the config.
+ * @return {Config}
+ */
+function generateConfig(configJson, flags) {
+  return new Config(configJson, flags);
+}
+
+lighthouse.generateConfig = generateConfig;
 lighthouse.getAuditList = Runner.getAuditList;
 lighthouse.traceCategories = require('./gather/driver').traceCategories;
 lighthouse.Audit = require('./audits/audit');
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 43309bc03..89c198a38 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -231,14 +231,23 @@ function createMessageInstanceIdFn(filename, fileStrings) {
   return getMessageInstanceIdFn;
 }
 
+/**
+ * Returns true if string is an ICUMessage reference.
+ * @param {string} icuMessageIdOrRawString
+ * @return {boolean}
+ */
+function isIcuMessage(icuMessageIdOrRawString) {
+  return MESSAGE_INSTANCE_ID_QUICK_REGEX.test(icuMessageIdOrRawString) &&
+      MESSAGE_INSTANCE_ID_REGEX.test(icuMessageIdOrRawString);
+}
+
 /**
  * @param {string} icuMessageIdOrRawString
  * @param {LH.Locale} locale
  * @return {string}
  */
 function getFormatted(icuMessageIdOrRawString, locale) {
-  if (MESSAGE_INSTANCE_ID_QUICK_REGEX.test(icuMessageIdOrRawString) &&
-      MESSAGE_INSTANCE_ID_REGEX.test(icuMessageIdOrRawString)) {
+  if (isIcuMessage(icuMessageIdOrRawString)) {
     return _resolveIcuMessageInstanceId(icuMessageIdOrRawString, locale).formattedString;
   }
 
@@ -265,24 +274,27 @@ function _resolveIcuMessageInstanceId(icuMessageInstanceId, locale) {
 }
 
 /**
- * @param {LH.Result} lhr
+ * Recursively walk the input object, looking for property values that are
+ * string references and replace them with their localized values. Primarily
+ * used with the full LHR as input.
+ * @param {*} inputObject
  * @param {LH.Locale} locale
+ * @return {LH.I18NMessages}
  */
-function replaceIcuMessageInstanceIds(lhr, locale) {
+function replaceIcuMessageInstanceIds(inputObject, locale) {
   /**
-   * @param {*} objectInLHR
+   * @param {*} subObject
    * @param {LH.I18NMessages} icuMessagePaths
    * @param {string[]} pathInLHR
    */
-  function replaceInObject(objectInLHR, icuMessagePaths, pathInLHR = []) {
-    if (typeof objectInLHR !== 'object' || !objectInLHR) return;
+  function replaceInObject(subObject, icuMessagePaths, pathInLHR = []) {
+    if (typeof subObject !== 'object' || !subObject) return;
 
-    for (const [property, value] of Object.entries(objectInLHR)) {
+    for (const [property, value] of Object.entries(subObject)) {
       const currentPathInLHR = pathInLHR.concat([property]);
 
       // Check to see if the value in the LHR looks like a string reference. If it is, replace it.
-      if (typeof value === 'string' && MESSAGE_INSTANCE_ID_QUICK_REGEX.test(value) &&
-          MESSAGE_INSTANCE_ID_REGEX.test(value)) {
+      if (typeof value === 'string' && isIcuMessage(value)) {
         const {icuMessageInstance, formattedString} = _resolveIcuMessageInstanceId(value, locale);
         const messageInstancesInLHR = icuMessagePaths[icuMessageInstance.icuMessageId] || [];
         const currentPathAsString = _formatPathAsString(currentPathInLHR);
@@ -293,7 +305,7 @@ function replaceIcuMessageInstanceIds(lhr, locale) {
             currentPathAsString
         );
 
-        objectInLHR[property] = formattedString;
+        subObject[property] = formattedString;
         icuMessagePaths[icuMessageInstance.icuMessageId] = messageInstancesInLHR;
       } else {
         replaceInObject(value, icuMessagePaths, currentPathInLHR);
@@ -303,7 +315,7 @@ function replaceIcuMessageInstanceIds(lhr, locale) {
 
   /** @type {LH.I18NMessages} */
   const icuMessagePaths = {};
-  replaceInObject(lhr, icuMessagePaths);
+  replaceInObject(inputObject, icuMessagePaths);
   return icuMessagePaths;
 }
 
@@ -315,4 +327,5 @@ module.exports = {
   createMessageInstanceIdFn,
   getFormatted,
   replaceIcuMessageInstanceIds,
+  isIcuMessage,
 };
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index e9d54f409..873c0b765 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -112,7 +112,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
   /**
    * @param {LH.ReportResult.Category} category
    * @param {Object<string, LH.Result.ReportGroup>} groups
-   * @param {string=} environment 'PSI' and undefined are the only valid values
+   * @param {'PSI'=} environment 'PSI' and undefined are the only valid values
    * @return {Element}
    * @override
    */
@@ -144,9 +144,13 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     otherMetrics.forEach(item => {
       metricsColumn2El.appendChild(this._renderMetric(item));
     });
-    const estValuesEl = this.dom.createChildOf(metricsColumn2El, 'div',
-        'lh-metrics__disclaimer lh-metrics__disclaimer');
-    estValuesEl.textContent = Util.UIStrings.varianceDisclaimer;
+
+    // 'Values are estimated and may vary' is used as the category description for PSI
+    if (environment !== 'PSI') {
+      const estValuesEl = this.dom.createChildOf(metricsColumn2El, 'div',
+          'lh-metrics__disclaimer lh-metrics__disclaimer');
+      estValuesEl.textContent = Util.UIStrings.varianceDisclaimer;
+    }
 
     metricAuditsEl.classList.add('lh-audit-group--metrics');
     element.appendChild(metricAuditsEl);
diff --git a/package.json b/package.json
index 0da238895..40bc8a7ae 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,6 @@
     "compile-devtools": "bash lighthouse-core/scripts/compile-against-devtools.sh",
     "chrome": "node lighthouse-core/scripts/manual-chrome-launcher.js",
     "fast": "yarn start --disable-device-emulation --throttlingMethod=provided",
-    "smokehouse": "node lighthouse-cli/test/smokehouse/smokehouse.js",
     "deploy-viewer": "cd lighthouse-viewer && gulp deploy",
     "bundlesize": "bundlesize",
     "plots-smoke": "bash plots/test/smoke.sh",
diff --git a/readme.md b/readme.md
index 784afc28e..802b8e709 100644
--- a/readme.md
+++ b/readme.md
@@ -49,6 +49,7 @@ Configuration:
   --save-assets                  Save the trace & devtools log to disk                                                                     [boolean]
   --list-all-audits              Prints a list of all available audits and exits                                                           [boolean]
   --list-trace-categories        Prints a list of all required trace categories and exits                                                  [boolean]
+  --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.
   --chrome-flags                 Custom flags to pass to Chrome (space-delimited). For a full list of flags, see
diff --git a/typings/externs.d.ts b/typings/externs.d.ts
index 1ca9bc710..9c322d7b9 100644
--- a/typings/externs.d.ts
+++ b/typings/externs.d.ts
@@ -73,6 +73,10 @@ declare global {
 
     export type OutputMode = 'json' | 'html' | 'csv';
 
+    /**
+     * Options that are found in both the flags used by the Lighthouse module
+     * interface and the Config's `settings` object.
+     */
     interface SharedFlagsSettings {
       output?: OutputMode|OutputMode[];
       locale?: Locale;
@@ -92,6 +96,10 @@ declare global {
       extraHeaders?: Crdp.Network.Headers | null; // See extraHeaders TODO in bin.js
     }
 
+    /**
+     * Extends the flags in SharedFlagsSettings with flags used to configure the
+     * Lighthouse module but will not end up in the Config settings.
+     */
     export interface Flags extends SharedFlagsSettings {
       port?: number;
       hostname?: string;
@@ -100,8 +108,8 @@ declare global {
     }
 
     /**
-     * Flags accepted by Lighthouse, plus additional flags just
-     * for controlling the CLI.
+     * Extends the flags accepted by the Lighthouse module with additional flags
+     * used just for controlling the CLI.
      */
     export interface CliFlags extends Flags {
       _: string[];
@@ -115,10 +123,11 @@ declare global {
       preset?: 'full'|'mixed-content'|'perf';
       verbose: boolean;
       quiet: boolean;
-      // following are given defaults in cli-flags, so not optional like in Flags or SharedFlagsSettings
+      // following are given defaults in cli-flags, so are not optional like in Flags or SharedFlagsSettings
       output: OutputMode[];
       port: number;
       hostname: string;
+      printConfig: boolean;
     }
 
     export interface RunnerResult {


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
new file mode 100644
index 000000000..2c3f4e57f
--- /dev/null
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -0,0 +1,1318 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CLI Tests print-config should print the default config and exit immediately after 1`] = `
+Object {
+  "audits": Array [
+    Object {
+      "path": "is-on-https",
+    },
+    Object {
+      "path": "redirects-http",
+    },
+    Object {
+      "path": "service-worker",
+    },
+    Object {
+      "path": "works-offline",
+    },
+    Object {
+      "path": "viewport",
+    },
+    Object {
+      "path": "without-javascript",
+    },
+    Object {
+      "path": "metrics/first-contentful-paint",
+    },
+    Object {
+      "path": "metrics/first-meaningful-paint",
+    },
+    Object {
+      "path": "load-fast-enough-for-pwa",
+    },
+    Object {
+      "path": "metrics/speed-index",
+    },
+    Object {
+      "path": "screenshot-thumbnails",
+    },
+    Object {
+      "path": "final-screenshot",
+    },
+    Object {
+      "path": "metrics/estimated-input-latency",
+    },
+    Object {
+      "path": "errors-in-console",
+    },
+    Object {
+      "path": "time-to-first-byte",
+    },
+    Object {
+      "path": "metrics/first-cpu-idle",
+    },
+    Object {
+      "path": "metrics/interactive",
+    },
+    Object {
+      "path": "user-timings",
+    },
+    Object {
+      "path": "critical-request-chains",
+    },
+    Object {
+      "path": "redirects",
+    },
+    Object {
+      "path": "webapp-install-banner",
+    },
+    Object {
+      "path": "splash-screen",
+    },
+    Object {
+      "path": "themed-omnibox",
+    },
+    Object {
+      "path": "manifest-short-name-length",
+    },
+    Object {
+      "path": "content-width",
+    },
+    Object {
+      "path": "image-aspect-ratio",
+    },
+    Object {
+      "path": "deprecations",
+    },
+    Object {
+      "path": "mainthread-work-breakdown",
+    },
+    Object {
+      "path": "bootup-time",
+    },
+    Object {
+      "path": "uses-rel-preload",
+    },
+    Object {
+      "path": "uses-rel-preconnect",
+    },
+    Object {
+      "path": "font-display",
+    },
+    Object {
+      "path": "network-requests",
+    },
+    Object {
+      "path": "metrics",
+    },
+    Object {
+      "path": "manual/pwa-cross-browser",
+    },
+    Object {
+      "path": "manual/pwa-page-transitions",
+    },
+    Object {
+      "path": "manual/pwa-each-page-has-url",
+    },
+    Object {
+      "path": "accessibility/accesskeys",
+    },
+    Object {
+      "path": "accessibility/aria-allowed-attr",
+    },
+    Object {
+      "path": "accessibility/aria-required-attr",
+    },
+    Object {
+      "path": "accessibility/aria-required-children",
+    },
+    Object {
+      "path": "accessibility/aria-required-parent",
+    },
+    Object {
+      "path": "accessibility/aria-roles",
+    },
+    Object {
+      "path": "accessibility/aria-valid-attr-value",
+    },
+    Object {
+      "path": "accessibility/aria-valid-attr",
+    },
+    Object {
+      "path": "accessibility/audio-caption",
+    },
+    Object {
+      "path": "accessibility/button-name",
+    },
+    Object {
+      "path": "accessibility/bypass",
+    },
+    Object {
+      "path": "accessibility/color-contrast",
+    },
+    Object {
+      "path": "accessibility/definition-list",
+    },
+    Object {
+      "path": "accessibility/dlitem",
+    },
+    Object {
+      "path": "accessibility/document-title",
+    },
+    Object {
+      "path": "accessibility/duplicate-id",
+    },
+    Object {
+      "path": "accessibility/frame-title",
+    },
+    Object {
+      "path": "accessibility/html-has-lang",
+    },
+    Object {
+      "path": "accessibility/html-lang-valid",
+    },
+    Object {
+      "path": "accessibility/image-alt",
+    },
+    Object {
+      "path": "accessibility/input-image-alt",
+    },
+    Object {
+      "path": "accessibility/label",
+    },
+    Object {
+      "path": "accessibility/layout-table",
+    },
+    Object {
+      "path": "accessibility/link-name",
+    },
+    Object {
+      "path": "accessibility/list",
+    },
+    Object {
+      "path": "accessibility/listitem",
+    },
+    Object {
+      "path": "accessibility/meta-refresh",
+    },
+    Object {
+      "path": "accessibility/meta-viewport",
+    },
+    Object {
+      "path": "accessibility/object-alt",
+    },
+    Object {
+      "path": "accessibility/tabindex",
+    },
+    Object {
+      "path": "accessibility/td-headers-attr",
+    },
+    Object {
+      "path": "accessibility/th-has-data-cells",
+    },
+    Object {
+      "path": "accessibility/valid-lang",
+    },
+    Object {
+      "path": "accessibility/video-caption",
+    },
+    Object {
+      "path": "accessibility/video-description",
+    },
+    Object {
+      "path": "accessibility/manual/custom-controls-labels",
+    },
+    Object {
+      "path": "accessibility/manual/custom-controls-roles",
+    },
+    Object {
+      "path": "accessibility/manual/focus-traps",
+    },
+    Object {
+      "path": "accessibility/manual/focusable-controls",
+    },
+    Object {
+      "path": "accessibility/manual/heading-levels",
+    },
+    Object {
+      "path": "accessibility/manual/interactive-element-affordance",
+    },
+    Object {
+      "path": "accessibility/manual/logical-tab-order",
+    },
+    Object {
+      "path": "accessibility/manual/managed-focus",
+    },
+    Object {
+      "path": "accessibility/manual/offscreen-content-hidden",
+    },
+    Object {
+      "path": "accessibility/manual/use-landmarks",
+    },
+    Object {
+      "path": "accessibility/manual/visual-order-follows-dom",
+    },
+    Object {
+      "path": "byte-efficiency/uses-long-cache-ttl",
+    },
+    Object {
+      "path": "byte-efficiency/total-byte-weight",
+    },
+    Object {
+      "path": "byte-efficiency/offscreen-images",
+    },
+    Object {
+      "path": "byte-efficiency/render-blocking-resources",
+    },
+    Object {
+      "path": "byte-efficiency/unminified-css",
+    },
+    Object {
+      "path": "byte-efficiency/unminified-javascript",
+    },
+    Object {
+      "path": "byte-efficiency/unused-css-rules",
+    },
+    Object {
+      "path": "byte-efficiency/uses-webp-images",
+    },
+    Object {
+      "path": "byte-efficiency/uses-optimized-images",
+    },
+    Object {
+      "path": "byte-efficiency/uses-text-compression",
+    },
+    Object {
+      "path": "byte-efficiency/uses-responsive-images",
+    },
+    Object {
+      "path": "byte-efficiency/efficient-animated-content",
+    },
+    Object {
+      "path": "dobetterweb/appcache-manifest",
+    },
+    Object {
+      "path": "dobetterweb/doctype",
+    },
+    Object {
+      "path": "dobetterweb/dom-size",
+    },
+    Object {
+      "path": "dobetterweb/external-anchors-use-rel-noopener",
+    },
+    Object {
+      "path": "dobetterweb/geolocation-on-start",
+    },
+    Object {
+      "path": "dobetterweb/no-document-write",
+    },
+    Object {
+      "path": "dobetterweb/no-vulnerable-libraries",
+    },
+    Object {
+      "path": "dobetterweb/js-libraries",
+    },
+    Object {
+      "path": "dobetterweb/no-websql",
+    },
+    Object {
+      "path": "dobetterweb/notification-on-start",
+    },
+    Object {
+      "path": "dobetterweb/password-inputs-can-be-pasted-into",
+    },
+    Object {
+      "path": "dobetterweb/uses-http2",
+    },
+    Object {
+      "path": "dobetterweb/uses-passive-event-listeners",
+    },
+    Object {
+      "path": "seo/meta-description",
+    },
+    Object {
+      "path": "seo/http-status-code",
+    },
+    Object {
+      "path": "seo/font-size",
+    },
+    Object {
+      "path": "seo/link-text",
+    },
+    Object {
+      "path": "seo/is-crawlable",
+    },
+    Object {
+      "path": "seo/robots-txt",
+    },
+    Object {
+      "path": "seo/hreflang",
+    },
+    Object {
+      "path": "seo/plugins",
+    },
+    Object {
+      "path": "seo/canonical",
+    },
+    Object {
+      "path": "seo/manual/mobile-friendly",
+    },
+    Object {
+      "path": "seo/manual/structured-data",
+    },
+  ],
+  "categories": Object {
+    "accessibility": Object {
+      "auditRefs": Array [
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "accesskeys",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-allowed-attr",
+          "weight": 3,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-required-attr",
+          "weight": 2,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-required-children",
+          "weight": 5,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-required-parent",
+          "weight": 2,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-roles",
+          "weight": 3,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-valid-attr-value",
+          "weight": 2,
+        },
+        Object {
+          "group": "a11y-aria",
+          "id": "aria-valid-attr",
+          "weight": 5,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "audio-caption",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-element-names",
+          "id": "button-name",
+          "weight": 10,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "bypass",
+          "weight": 10,
+        },
+        Object {
+          "group": "a11y-color-contrast",
+          "id": "color-contrast",
+          "weight": 6,
+        },
+        Object {
+          "group": "a11y-well-structured",
+          "id": "definition-list",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-well-structured",
+          "id": "dlitem",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "document-title",
+          "weight": 2,
+        },
+        Object {
+          "group": "a11y-well-structured",
+          "id": "duplicate-id",
+          "weight": 5,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "frame-title",
+          "weight": 5,
+        },
+        Object {
+          "group": "a11y-language",
+          "id": "html-has-lang",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-language",
+          "id": "html-lang-valid",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "image-alt",
+          "weight": 8,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "input-image-alt",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "label",
+          "weight": 10,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "layout-table",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-element-names",
+          "id": "link-name",
+          "weight": 9,
+        },
+        Object {
+          "group": "a11y-well-structured",
+          "id": "list",
+          "weight": 5,
+        },
+        Object {
+          "group": "a11y-well-structured",
+          "id": "listitem",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-meta",
+          "id": "meta-refresh",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-meta",
+          "id": "meta-viewport",
+          "weight": 3,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "object-alt",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "tabindex",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "td-headers-attr",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-correct-attributes",
+          "id": "th-has-data-cells",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-language",
+          "id": "valid-lang",
+          "weight": 1,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "video-caption",
+          "weight": 4,
+        },
+        Object {
+          "group": "a11y-describe-contents",
+          "id": "video-description",
+          "weight": 3,
+        },
+        Object {
+          "id": "logical-tab-order",
+          "weight": 0,
+        },
+        Object {
+          "id": "focusable-controls",
+          "weight": 0,
+        },
+        Object {
+          "id": "interactive-element-affordance",
+          "weight": 0,
+        },
+        Object {
+          "id": "managed-focus",
+          "weight": 0,
+        },
+        Object {
+          "id": "focus-traps",
+          "weight": 0,
+        },
+        Object {
+          "id": "custom-controls-labels",
+          "weight": 0,
+        },
+        Object {
+          "id": "custom-controls-roles",
+          "weight": 0,
+        },
+        Object {
+          "id": "visual-order-follows-dom",
+          "weight": 0,
+        },
+        Object {
+          "id": "offscreen-content-hidden",
+          "weight": 0,
+        },
+        Object {
+          "id": "heading-levels",
+          "weight": 0,
+        },
+        Object {
+          "id": "use-landmarks",
+          "weight": 0,
+        },
+      ],
+      "description": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.",
+      "manualDescription": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).",
+      "title": "Accessibility",
+    },
+    "best-practices": Object {
+      "auditRefs": Array [
+        Object {
+          "id": "appcache-manifest",
+          "weight": 1,
+        },
+        Object {
+          "id": "no-websql",
+          "weight": 1,
+        },
+        Object {
+          "id": "is-on-https",
+          "weight": 1,
+        },
+        Object {
+          "id": "uses-http2",
+          "weight": 1,
+        },
+        Object {
+          "id": "uses-passive-event-listeners",
+          "weight": 1,
+        },
+        Object {
+          "id": "no-document-write",
+          "weight": 1,
+        },
+        Object {
+          "id": "external-anchors-use-rel-noopener",
+          "weight": 1,
+        },
+        Object {
+          "id": "geolocation-on-start",
+          "weight": 1,
+        },
+        Object {
+          "id": "doctype",
+          "weight": 1,
+        },
+        Object {
+          "id": "no-vulnerable-libraries",
+          "weight": 1,
+        },
+        Object {
+          "id": "js-libraries",
+          "weight": 0,
+        },
+        Object {
+          "id": "notification-on-start",
+          "weight": 1,
+        },
+        Object {
+          "id": "deprecations",
+          "weight": 1,
+        },
+        Object {
+          "id": "password-inputs-can-be-pasted-into",
+          "weight": 1,
+        },
+        Object {
+          "id": "errors-in-console",
+          "weight": 1,
+        },
+        Object {
+          "id": "image-aspect-ratio",
+          "weight": 1,
+        },
+      ],
+      "title": "Best Practices",
+    },
+    "performance": Object {
+      "auditRefs": Array [
+        Object {
+          "group": "metrics",
+          "id": "first-contentful-paint",
+          "weight": 3,
+        },
+        Object {
+          "group": "metrics",
+          "id": "first-meaningful-paint",
+          "weight": 1,
+        },
+        Object {
+          "group": "metrics",
+          "id": "speed-index",
+          "weight": 4,
+        },
+        Object {
+          "group": "metrics",
+          "id": "interactive",
+          "weight": 5,
+        },
+        Object {
+          "group": "metrics",
+          "id": "first-cpu-idle",
+          "weight": 2,
+        },
+        Object {
+          "group": "metrics",
+          "id": "estimated-input-latency",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "render-blocking-resources",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-responsive-images",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "offscreen-images",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "unminified-css",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "unminified-javascript",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "unused-css-rules",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-optimized-images",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-webp-images",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-text-compression",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-rel-preconnect",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "time-to-first-byte",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "redirects",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "uses-rel-preload",
+          "weight": 0,
+        },
+        Object {
+          "group": "load-opportunities",
+          "id": "efficient-animated-content",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "total-byte-weight",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "uses-long-cache-ttl",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "dom-size",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "critical-request-chains",
+          "weight": 0,
+        },
+        Object {
+          "id": "network-requests",
+          "weight": 0,
+        },
+        Object {
+          "id": "metrics",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "user-timings",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "bootup-time",
+          "weight": 0,
+        },
+        Object {
+          "id": "screenshot-thumbnails",
+          "weight": 0,
+        },
+        Object {
+          "id": "final-screenshot",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "mainthread-work-breakdown",
+          "weight": 0,
+        },
+        Object {
+          "group": "diagnostics",
+          "id": "font-display",
+          "weight": 0,
+        },
+      ],
+      "title": "Performance",
+    },
+    "pwa": Object {
+      "auditRefs": Array [
+        Object {
+          "id": "load-fast-enough-for-pwa",
+          "weight": 7,
+        },
+        Object {
+          "id": "works-offline",
+          "weight": 5,
+        },
+        Object {
+          "id": "webapp-install-banner",
+          "weight": 3,
+        },
+        Object {
+          "id": "is-on-https",
+          "weight": 2,
+        },
+        Object {
+          "id": "redirects-http",
+          "weight": 2,
+        },
+        Object {
+          "id": "viewport",
+          "weight": 2,
+        },
+        Object {
+          "id": "service-worker",
+          "weight": 1,
+        },
+        Object {
+          "id": "without-javascript",
+          "weight": 1,
+        },
+        Object {
+          "id": "splash-screen",
+          "weight": 1,
+        },
+        Object {
+          "id": "themed-omnibox",
+          "weight": 1,
+        },
+        Object {
+          "id": "content-width",
+          "weight": 1,
+        },
+        Object {
+          "id": "manifest-short-name-length",
+          "weight": 0,
+        },
+        Object {
+          "id": "pwa-cross-browser",
+          "weight": 0,
+        },
+        Object {
+          "id": "pwa-page-transitions",
+          "weight": 0,
+        },
+        Object {
+          "id": "pwa-each-page-has-url",
+          "weight": 0,
+        },
+      ],
+      "description": "These checks validate the aspects of a Progressive Web App, as specified by the baseline [PWA Checklist](https://developers.google.com/web/progressive-web-apps/checklist).",
+      "manualDescription": "These checks are required by the baseline [PWA Checklist](https://developers.google.com/web/progressive-web-apps/checklist) but are not automatically checked by Lighthouse. They do not affect your score but it's important that you verify them manually.",
+      "title": "Progressive Web App",
+    },
+    "seo": Object {
+      "auditRefs": Array [
+        Object {
+          "group": "seo-mobile",
+          "id": "viewport",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "document-title",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "meta-description",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-crawl",
+          "id": "http-status-code",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "link-text",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-crawl",
+          "id": "is-crawlable",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-crawl",
+          "id": "robots-txt",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "hreflang",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "canonical",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-mobile",
+          "id": "font-size",
+          "weight": 1,
+        },
+        Object {
+          "group": "seo-content",
+          "id": "plugins",
+          "weight": 1,
+        },
+        Object {
+          "id": "mobile-friendly",
+          "weight": 0,
+        },
+        Object {
+          "id": "structured-data",
+          "weight": 0,
+        },
+      ],
+      "description": "These checks ensure that your page is optimized for search engine results ranking. There are additional factors Lighthouse does not check that may affect your search ranking. [Learn more](https://support.google.com/webmasters/answer/35769).",
+      "manualDescription": "Run these additional validators on your site to check additional SEO best practices.",
+      "title": "SEO",
+    },
+  },
+  "groups": Object {
+    "a11y-aria": Object {
+      "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "ARIA Attributes Follow Best Practices",
+    },
+    "a11y-color-contrast": Object {
+      "description": "These are opportunities to improve the legibility of your content.",
+      "title": "Color Contrast Is Satisfactory",
+    },
+    "a11y-correct-attributes": Object {
+      "description": "These are opportunities to improve the configuration of your HTML elements.",
+      "title": "Elements Use Attributes Correctly",
+    },
+    "a11y-describe-contents": Object {
+      "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.",
+      "title": "Elements Describe Contents Well",
+    },
+    "a11y-element-names": Object {
+      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "Elements Have Discernible Names",
+    },
+    "a11y-language": Object {
+      "description": "These are opportunities to improve the interpretation of your content by users in different locales.",
+      "title": "Page Specifies Valid Language",
+    },
+    "a11y-meta": Object {
+      "description": "These are opportunities to improve the user experience of your site.",
+      "title": "Meta Tags Used Properly",
+    },
+    "a11y-well-structured": Object {
+      "description": "These are opportunities to make sure your HTML is appropriately structured.",
+      "title": "Elements Are Well Structured",
+    },
+    "diagnostics": Object {
+      "description": "More information about the performance of your application.",
+      "title": "Diagnostics",
+    },
+    "load-opportunities": Object {
+      "description": "These optimizations can speed up your page load.",
+      "title": "Opportunities",
+    },
+    "metrics": Object {
+      "title": "Metrics",
+    },
+    "seo-content": Object {
+      "description": "Format your HTML in a way that enables crawlers to better understand your app’s content.",
+      "title": "Content Best Practices",
+    },
+    "seo-crawl": Object {
+      "description": "To appear in search results, crawlers need access to your app.",
+      "title": "Crawling and Indexing",
+    },
+    "seo-mobile": Object {
+      "description": "Make sure your pages are mobile friendly so users don’t have to pinch or zoom in order to read the content pages. [Learn more](https://developers.google.com/search/mobile-sites/).",
+      "title": "Mobile Friendly",
+    },
+  },
+  "passes": Array [
+    Object {
+      "blankDuration": 300,
+      "blankPage": "about:blank",
+      "blockedUrlPatterns": Array [],
+      "cpuQuietThresholdMs": 1000,
+      "gatherers": Array [
+        Object {
+          "path": "scripts",
+        },
+        Object {
+          "path": "css-usage",
+        },
+        Object {
+          "path": "viewport",
+        },
+        Object {
+          "path": "viewport-dimensions",
+        },
+        Object {
+          "path": "theme-color",
+        },
+        Object {
+          "path": "manifest",
+        },
+        Object {
+          "path": "runtime-exceptions",
+        },
+        Object {
+          "path": "chrome-console-messages",
+        },
+        Object {
+          "path": "image-usage",
+        },
+        Object {
+          "path": "accessibility",
+        },
+        Object {
+          "path": "dobetterweb/anchors-with-no-rel-noopener",
+        },
+        Object {
+          "path": "dobetterweb/appcache",
+        },
+        Object {
+          "path": "dobetterweb/doctype",
+        },
+        Object {
+          "path": "dobetterweb/domstats",
+        },
+        Object {
+          "path": "dobetterweb/js-libraries",
+        },
+        Object {
+          "path": "dobetterweb/optimized-images",
+        },
+        Object {
+          "path": "dobetterweb/password-inputs-with-prevented-paste",
+        },
+        Object {
+          "path": "dobetterweb/response-compression",
+        },
+        Object {
+          "path": "dobetterweb/tags-blocking-first-paint",
+        },
+        Object {
+          "path": "dobetterweb/websql",
+        },
+        Object {
+          "path": "seo/meta-description",
+        },
+        Object {
+          "path": "seo/font-size",
+        },
+        Object {
+          "path": "seo/crawlable-links",
+        },
+        Object {
+          "path": "seo/meta-robots",
+        },
+        Object {
+          "path": "seo/hreflang",
+        },
+        Object {
+          "path": "seo/embedded-content",
+        },
+        Object {
+          "path": "seo/canonical",
+        },
+        Object {
+          "path": "seo/robots-txt",
+        },
+        Object {
+          "path": "fonts",
+        },
+      ],
+      "networkQuietThresholdMs": 1000,
+      "passName": "defaultPass",
+      "pauseAfterLoadMs": 1000,
+      "recordTrace": true,
+      "useThrottling": true,
+    },
+    Object {
+      "blankDuration": 300,
+      "blankPage": "about:blank",
+      "blockedUrlPatterns": Array [],
+      "cpuQuietThresholdMs": 0,
+      "gatherers": Array [
+        Object {
+          "path": "service-worker",
+        },
+        Object {
+          "path": "offline",
+        },
+        Object {
+          "path": "start-url",
+        },
+      ],
+      "networkQuietThresholdMs": 0,
+      "passName": "offlinePass",
+      "pauseAfterLoadMs": 0,
+      "recordTrace": false,
+      "useThrottling": false,
+    },
+    Object {
+      "blankDuration": 300,
+      "blankPage": "about:blank",
+      "blockedUrlPatterns": Array [
+        "*.css",
+        "*.jpg",
+        "*.jpeg",
+        "*.png",
+        "*.gif",
+        "*.svg",
+        "*.ttf",
+        "*.woff",
+        "*.woff2",
+      ],
+      "cpuQuietThresholdMs": 0,
+      "gatherers": Array [
+        Object {
+          "path": "http-redirect",
+        },
+        Object {
+          "path": "html-without-javascript",
+        },
+      ],
+      "networkQuietThresholdMs": 0,
+      "passName": "redirectPass",
+      "pauseAfterLoadMs": 0,
+      "recordTrace": false,
+      "useThrottling": false,
+    },
+  ],
+  "settings": Object {
+    "additionalTraceCategories": null,
+    "auditMode": false,
+    "blockedUrlPatterns": null,
+    "disableDeviceEmulation": false,
+    "disableStorageReset": false,
+    "emulatedFormFactor": "mobile",
+    "extraHeaders": null,
+    "gatherMode": false,
+    "locale": "en-US",
+    "maxWaitForLoad": 45000,
+    "onlyAudits": null,
+    "onlyCategories": null,
+    "output": Array [
+      "html",
+    ],
+    "skipAudits": null,
+    "throttling": Object {
+      "cpuSlowdownMultiplier": 4,
+      "downloadThroughputKbps": 1474.5600000000002,
+      "requestLatencyMs": 562.5,
+      "rttMs": 150,
+      "throughputKbps": 1638.4,
+      "uploadThroughputKbps": 675,
+    },
+    "throttlingMethod": "simulate",
+  },
+}
+`;
+
+exports[`CLI Tests print-config should print the overridden config and exit immediately after 1`] = `
+Object {
+  "audits": Array [
+    Object {
+      "path": "metrics",
+    },
+  ],
+  "categories": Object {
+    "performance": Object {
+      "auditRefs": Array [
+        Object {
+          "id": "metrics",
+          "weight": 0,
+        },
+      ],
+      "title": "Performance",
+    },
+  },
+  "groups": Object {
+    "a11y-aria": Object {
+      "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "ARIA Attributes Follow Best Practices",
+    },
+    "a11y-color-contrast": Object {
+      "description": "These are opportunities to improve the legibility of your content.",
+      "title": "Color Contrast Is Satisfactory",
+    },
+    "a11y-correct-attributes": Object {
+      "description": "These are opportunities to improve the configuration of your HTML elements.",
+      "title": "Elements Use Attributes Correctly",
+    },
+    "a11y-describe-contents": Object {
+      "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.",
+      "title": "Elements Describe Contents Well",
+    },
+    "a11y-element-names": Object {
+      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "Elements Have Discernible Names",
+    },
+    "a11y-language": Object {
+      "description": "These are opportunities to improve the interpretation of your content by users in different locales.",
+      "title": "Page Specifies Valid Language",
+    },
+    "a11y-meta": Object {
+      "description": "These are opportunities to improve the user experience of your site.",
+      "title": "Meta Tags Used Properly",
+    },
+    "a11y-well-structured": Object {
+      "description": "These are opportunities to make sure your HTML is appropriately structured.",
+      "title": "Elements Are Well Structured",
+    },
+    "diagnostics": Object {
+      "description": "More information about the performance of your application.",
+      "title": "Diagnostics",
+    },
+    "load-opportunities": Object {
+      "description": "These optimizations can speed up your page load.",
+      "title": "Opportunities",
+    },
+    "metrics": Object {
+      "title": "Metrics",
+    },
+    "seo-content": Object {
+      "description": "Format your HTML in a way that enables crawlers to better understand your app’s content.",
+      "title": "Content Best Practices",
+    },
+    "seo-crawl": Object {
+      "description": "To appear in search results, crawlers need access to your app.",
+      "title": "Crawling and Indexing",
+    },
+    "seo-mobile": Object {
+      "description": "Make sure your pages are mobile friendly so users don’t have to pinch or zoom in order to read the content pages. [Learn more](https://developers.google.com/search/mobile-sites/).",
+      "title": "Mobile Friendly",
+    },
+  },
+  "passes": Array [
+    Object {
+      "blankDuration": 300,
+      "blankPage": "about:blank",
+      "blockedUrlPatterns": Array [],
+      "cpuQuietThresholdMs": 1000,
+      "gatherers": Array [],
+      "networkQuietThresholdMs": 1000,
+      "passName": "defaultPass",
+      "pauseAfterLoadMs": 1000,
+      "recordTrace": true,
+      "useThrottling": true,
+    },
+  ],
+  "settings": Object {
+    "additionalTraceCategories": null,
+    "auditMode": true,
+    "blockedUrlPatterns": null,
+    "disableDeviceEmulation": false,
+    "disableStorageReset": false,
+    "emulatedFormFactor": "mobile",
+    "extraHeaders": null,
+    "gatherMode": false,
+    "locale": "en-US",
+    "maxWaitForLoad": 45000,
+    "onlyAudits": Array [
+      "metrics",
+    ],
+    "onlyCategories": null,
+    "output": Array [
+      "json",
+    ],
+    "skipAudits": null,
+    "throttling": Object {
+      "cpuSlowdownMultiplier": 4,
+      "downloadThroughputKbps": 1474.5600000000002,
+      "requestLatencyMs": 562.5,
+      "rttMs": 150,
+      "throughputKbps": 1638.4,
+      "uploadThroughputKbps": 675,
+    },
+    "throttlingMethod": "simulate",
+  },
+}
+`;
diff --git a/lighthouse-cli/test/cli/index-test.js b/lighthouse-cli/test/cli/index-test.js
index cb82020cf..11d31388b 100644
--- a/lighthouse-cli/test/cli/index-test.js
+++ b/lighthouse-cli/test/cli/index-test.js
@@ -67,4 +67,32 @@ describe('CLI Tests', function() {
       assert.equal(ret.status, 1);
     });
   });
+
+  describe('print-config', () => {
+    it('should print the default config and exit immediately after', () => {
+      const ret = spawnSync('node', [indexPath, '--print-config'], {encoding: 'utf8'});
+
+      const config = JSON.parse(ret.stdout);
+      assert.strictEqual(config.settings.output[0], 'html');
+      assert.strictEqual(config.settings.auditMode, false);
+
+      expect(config).toMatchSnapshot();
+    });
+
+    it('should print the overridden config and exit immediately after', () => {
+      const flags = [
+        '--print-config', '-A',
+        '--output', 'json',
+        '--only-audits', 'metrics',
+      ];
+      const ret = spawnSync('node', [indexPath, ...flags], {encoding: 'utf8'});
+
+      const config = JSON.parse(ret.stdout);
+      assert.strictEqual(config.settings.output[0], 'json');
+      assert.strictEqual(config.settings.auditMode, true);
+      assert.strictEqual(config.audits.length, 1);
+
+      expect(config).toMatchSnapshot();
+    });
+  });
 });
diff --git a/lighthouse-cli/test/fixtures/static-server.js b/lighthouse-cli/test/fixtures/static-server.js
index 3e5079991..832944a41 100644
--- a/lighthouse-cli/test/fixtures/static-server.js
+++ b/lighthouse-cli/test/fixtures/static-server.js
@@ -35,9 +35,9 @@ function requestHandler(request, response) {
     const zonePath = '../../../node_modules/zone.js';
     absoluteFilePath = path.join(__dirname, `${zonePath}/dist/zone.js`);
   } else {
-    // Otherwise, disallow file requests outside of LH folder or from node_modules
+    // Otherwise, disallow file requests outside of LH folder
     const filePathDir = path.parse(absoluteFilePath).dir;
-    if (!filePathDir.startsWith(lhRootDirPath) || filePathDir.includes('node_modules')) {
+    if (!filePathDir.startsWith(lhRootDirPath)) {
       return readFileCallback(new Error('Disallowed path'));
     }
   }
diff --git a/lighthouse-core/test/audits/content-width-test.js b/lighthouse-core/test/audits/content-width-test.js
index 41f8f2ed3..e4ce6e8b4 100644
--- a/lighthouse-core/test/audits/content-width-test.js
+++ b/lighthouse-core/test/audits/content-width-test.js
@@ -13,11 +13,25 @@ const assert = require('assert');
 describe('Mobile-friendly: content-width audit', () => {
   it('fails when scroll width differs from viewport width', () => {
     const result = Audit.audit({
+      HostUserAgent: 'Desktop',
       ViewportDimensions: {
         innerWidth: 100,
         outerWidth: 300,
       },
-    });
+    }, {settings: {emulatedFormFactor: 'mobile'}});
+
+    assert.equal(result.rawValue, false);
+    assert.ok(result.explanation);
+  });
+
+  it('fails when host user agent is a phone', () => {
+    const result = Audit.audit({
+      HostUserAgent: 'Mobile Android',
+      ViewportDimensions: {
+        innerWidth: 100,
+        outerWidth: 300,
+      },
+    }, {settings: {emulatedFormFactor: 'none'}});
 
     assert.equal(result.rawValue, false);
     assert.ok(result.explanation);
@@ -25,10 +39,21 @@ describe('Mobile-friendly: content-width audit', () => {
 
   it('passes when widths match', () => {
     return assert.equal(Audit.audit({
+      HostUserAgent: '',
       ViewportDimensions: {
         innerWidth: 300,
         outerWidth: 300,
       },
-    }).rawValue, true);
+    }, {settings: {emulatedFormFactor: 'mobile'}}).rawValue, true);
+  });
+
+  it('not applicable when device emulation is turned off', () => {
+    return assert.equal(Audit.audit({
+      HostUserAgent: 'Mobile Android Chrome',
+      ViewportDimensions: {
+        innerWidth: 300,
+        outerWidth: 450,
+      },
+    }, {settings: {emulatedFormFactor: 'desktop'}}).notApplicable, true);
   });
 });
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index 12b315ab6..48c08cd1e 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -974,4 +974,76 @@ describe('Config', () => {
         /afterPass\(\) method/);
     });
   });
+
+  describe('#getDisplayString', () => {
+    it('doesn\'t include empty gatherer/audit options in output', () => {
+      const gOpt = 'gathererOption';
+      const aOpt = 'auditOption';
+      const configJson = {
+        extends: 'lighthouse:default',
+        passes: [{
+          passName: 'defaultPass',
+          gatherers: [
+            // `options` merged into default `viewport` gatherer.
+            {path: 'viewport', options: {gOpt}},
+          ],
+        }],
+        audits: [
+          // `options` merged into default `metrics` audit.
+          {path: 'metrics', options: {aOpt}},
+        ],
+      };
+
+      const printed = new Config(configJson).getPrintString();
+      const printedConfig = JSON.parse(printed);
+
+      // Check that options weren't completely eliminated.
+      const viewportGatherer = printedConfig.passes[0].gatherers.find(g => g.path === 'viewport');
+      assert.strictEqual(viewportGatherer.options.gOpt, gOpt);
+      const metricsAudit = printedConfig.audits.find(a => a.path === 'metrics');
+      assert.strictEqual(metricsAudit.options.aOpt, aOpt);
+
+      for (const pass of printedConfig.passes) {
+        for (const gatherer of pass.gatherers) {
+          if (gatherer.options) {
+            assert.ok(Object.keys(gatherer.options).length > 0);
+          }
+        }
+      }
+
+      for (const audit of printedConfig.audits) {
+        if (audit.options) {
+          assert.ok(Object.keys(audit.options).length > 0);
+        }
+      }
+    });
+
+    it('prints localized category titles', () => {
+      const printed = new Config(defaultConfig).getPrintString();
+      const printedConfig = JSON.parse(printed);
+      let localizableCount = 0;
+
+      Object.entries(printedConfig.categories).forEach(([printedCategoryId, printedCategory]) => {
+        const origTitle = origConfig.categories[printedCategoryId].title;
+        if (i18n.isIcuMessage(origTitle)) localizableCount++;
+        const i18nOrigTitle = i18n.getFormatted(origTitle, origConfig.settings.locale);
+
+        assert.strictEqual(printedCategory.title, i18nOrigTitle);
+      });
+
+      // Should have localized at least one string.
+      assert.ok(localizableCount > 0);
+    });
+
+    it('prints a valid ConfigJson that can make an identical Config', () => {
+      // depends on defaultConfig having a `path` for all gatherers and audits.
+      const firstConfig = new Config(defaultConfig);
+      const firstPrint = firstConfig.getPrintString();
+
+      const secondConfig = new Config(JSON.parse(firstPrint));
+      const secondPrint = secondConfig.getPrintString();
+
+      assert.strictEqual(firstPrint, secondPrint);
+    });
+  });
 });