Repository: GoogleChrome/lighthouse
Pull request #8630 (2019-04-25 22:07:33)

misc: simplify LH.Config settings types
We missed this simplification while doing the flags/settings unification stuff.

We needed `extraHeaders` to be a more specific type for configJson settings at some point, but it's now the same type as on the base `SharedFlagsSettings`, so `LH.Config.Json['settings']` can just be `SharedFlagsSettings`, as you'd pretty much expect.


Patch:
diff --git a/build/build-dt-report-resources.js b/build/build-dt-report-resources.js
index 3abfc03c3..c0526aeed 100644
--- a/build/build-dt-report-resources.js
+++ b/build/build-dt-report-resources.js
@@ -23,8 +23,21 @@ const htmlReportAssets = require('../lighthouse-core/report/html/html-report-ass
  */
 function convertToAsciiAndWriteFile(name, content) {
   assert(content);
-  // eslint-disable-next-line no-control-regex
-  const escaped = content.replace(/[^\x00-\x7F]/g, c => '\\\\u' + c.charCodeAt(0).toString(16));
+
+  let unicodeEscapePrefix = '\\\\u'; // js
+  if (name.endsWith('.html')) {
+    // Can't support unicode characters in inline stylesheets or js, would have to parse
+    // and apply context-specific escapes. Instead, since no ascii is used in html yet,
+    // punt and throw if any ascii is found.
+    // eslint-disable-next-line no-control-regex
+    assert(!content.match(/[^\x00-\x7F]/));
+  } else if (name.endsWith('.css')) {
+    unicodeEscapePrefix = '\\';
+  }
+
+  const escaped =
+    // eslint-disable-next-line no-control-regex
+    content.replace(/[^\x00-\x7F]/g, c => unicodeEscapePrefix + c.charCodeAt(0).toString(16));
   fs.writeFileSync(`${distDir}/${name}`, escaped);
 }
 
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 9676a8f4a..bf33fe7c2 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -6,13 +6,25 @@
 'use strict';
 
 /** @typedef {typeof import('./extension-entry.js') & {console: typeof console}} BackgroundPage */
+/** @typedef {import('../../../lighthouse-core/lib/lh-error.js')} LighthouseError */
 
 /**
  * Error strings that indicate a problem in how Lighthouse was run, not in
  * Lighthouse itself, mapped to more useful strings to report to the user.
  */
 const NON_BUG_ERROR_MESSAGES = {
-  'Another debugger': 'You probably have DevTools open. Close DevTools to use Lighthouse',
+  // The user tries to review an error page or has network issues
+  'ERRORED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
+      'are trying to review.',
+  'FAILED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
+      'are trying to review.',
+  'DNS_FAILURE': 'DNS servers could not resolve the provided domain.',
+  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have a valid' +
+      ' SSL certificate.',
+  'INVALID_URL': 'Lighthouse can only audit URLs that start' +
+      ' with http:// or https://.',
+
+  // chrome extension API errors
   'multiple tabs': 'You probably have multiple tabs open to the same origin. ' +
       'Close the other tabs to use Lighthouse.',
   // The extension debugger API is forbidden from attaching to the web store.
@@ -21,19 +33,10 @@ const NON_BUG_ERROR_MESSAGES = {
       'Chrome Web Store. If necessary, use the Lighthouse CLI to do so.',
   'Cannot access a chrome': 'The Lighthouse extension cannot audit ' +
       'Chrome-specific urls. If necessary, use the Lighthouse CLI to do so.',
-  // The user tries to review an error page or has network issues
-  'Unable to load the page': 'Unable to load the page. Please verify the url you ' +
-      'are trying to review.',
   'Cannot access contents of the page': 'Lighthouse can only audit URLs that start' +
       ' with http:// or https://.',
-  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have valid' +
-      ' security credentials.',
-  'INVALID_URL': 'Lighthouse can only audit URLs that start' +
-      ' with http:// or https://.',
 };
 
-const MAX_ISSUE_ERROR_LENGTH = 60;
-
 const subpageVisibleClass = 'subpage--visible';
 
 /** @type {?URL} */
@@ -79,35 +82,40 @@ function find(query, context = document) {
 }
 
 /**
+ * @param {string} message
  * @param {Error} err
- * @return {HTMLAnchorElement}
+ * @return {HTMLButtonElement}
  */
-function buildReportErrorLink(err) {
+function buildErrorCopyButton(message, err) {
   const issueBody = `
 **Lighthouse Version**: ${getLighthouseVersion()}
 **Lighthouse Commit**: ${getLighthouseCommitHash()}
 **Chrome Version**: ${getChromeVersion()}
 **Initial URL**: ${siteURL}
-**Error Message**: ${err.message}
+**Error Message**: ${message}
 **Stack Trace**:
 \`\`\`
 ${err.stack}
 \`\`\`
     `;
 
-  const url = new URL('https://github.com/GoogleChrome/lighthouse/issues/new');
+  const errorButtonDefaultText = 'Copy details to clipboard 📋';
+  const errorButtonEl = document.createElement('button');
+  errorButtonEl.className = 'button button--report-error';
+  errorButtonEl.textContent = errorButtonDefaultText;
 
-  const errorTitle = err.message.substring(0, MAX_ISSUE_ERROR_LENGTH);
-  url.searchParams.append('title', `Extension Error: ${errorTitle}`);
-  url.searchParams.append('body', issueBody.trim());
+  errorButtonEl.addEventListener('click', async () => {
+    // @ts-ignore - tsc doesn't include `clipboard` on `navigator`
+    await navigator.clipboard.writeText(issueBody);
+    errorButtonEl.textContent = 'Copied to clipboard 📋';
 
-  const reportErrorEl = document.createElement('a');
-  reportErrorEl.className = 'button button--report-error';
-  reportErrorEl.href = url.href;
-  reportErrorEl.textContent = 'Report Error';
-  reportErrorEl.target = '_blank';
+    // Return button to inviting state after timeout.
+    setTimeout(() => {
+      errorButtonEl.textContent = errorButtonDefaultText;
+    }, 1000);
+  });
 
-  return reportErrorEl;
+  return errorButtonEl;
 }
 
 /**
@@ -176,13 +184,14 @@ async function onGenerateReportButtonClick(background, settings) {
     // Close popup once report is opened in a new tab
     window.close();
   } catch (err) {
-    let message = err.message;
+    let message = err.friendlyMessage || err.message;
     let includeReportLink = true;
 
     // Check for errors in how the user ran Lighthouse and replace with a more
     // helpful message (and remove 'Report Error' link).
     for (const [test, replacement] of Object.entries(NON_BUG_ERROR_MESSAGES)) {
-      if (message.includes(test)) {
+      if (err.message.includes(test) ||
+          (err.friendlyMessage && err.friendlyMessage.includes(test))) {
         message = replacement;
         includeReportLink = false;
         break;
@@ -193,7 +202,7 @@ async function onGenerateReportButtonClick(background, settings) {
 
     if (includeReportLink) {
       feedbackEl.className = 'feedback feedback-error';
-      feedbackEl.appendChild(buildReportErrorLink(err));
+      feedbackEl.appendChild(buildErrorCopyButton(message, err));
     }
 
     hideRunningSubpage();
diff --git a/clients/extension/styles/lighthouse.css b/clients/extension/styles/lighthouse.css
index 01d62639a..cb54d65ef 100644
--- a/clients/extension/styles/lighthouse.css
+++ b/clients/extension/styles/lighthouse.css
@@ -132,8 +132,8 @@ html, body {
   padding: 3px 5px;
   font-size: .8em;
   text-align: center;
-  display: inline;
-  margin-left: 5px;
+  display: block;
+  margin-top: 5px;
 }
 
 .subpage {
diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js
index 510a766e4..887a533d8 100644
--- a/lighthouse-cli/bin.js
+++ b/lighthouse-cli/bin.js
@@ -77,6 +77,13 @@ async function begin() {
     configJson = require(`../lighthouse-core/config/${cliFlags.preset}-config.js`);
   }
 
+  if (cliFlags.budgetPath) {
+    cliFlags.budgetPath = path.resolve(process.cwd(), cliFlags.budgetPath);
+    /** @type {Array<LH.Budget>} */
+    const parsedBudget = JSON.parse(fs.readFileSync(cliFlags.budgetPath, 'utf8'));
+    cliFlags.budgets = parsedBudget;
+  }
+
   // set logging preferences
   cliFlags.logLevel = 'info';
   if (cliFlags.verbose) {
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 58c1939a6..06e96f93d 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -59,7 +59,7 @@ function getFlags(manualArgv) {
           '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',
+          'only-audits', 'only-categories', 'skip-audits', 'budget-path',
         ],
         'Configuration:')
       .describe({
@@ -88,6 +88,7 @@ function getFlags(manualArgv) {
             '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`,
+        'budget-path': `The path to the budget.json file for LightWallet.`,
         'preset': `Use a built-in configuration.
             WARNING: If the --config-path flag is provided, this preset will be ignored.`,
         'chrome-flags':
@@ -141,6 +142,7 @@ function getFlags(manualArgv) {
       .string('channel')
       .string('precomputedLanternDataPath')
       .string('lanternDataOutputPath')
+      .string('budgetPath')
 
       // default values
       .default('chrome-flags', '')
diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js
new file mode 100644
index 000000000..820dda302
--- /dev/null
+++ b/lighthouse-core/config/budget.js
@@ -0,0 +1,131 @@
+/**
+ * @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';
+
+class Budget {
+  /**
+   * Asserts that obj has no own properties, throwing a nice error message if it does.
+   * Plugin and object name are included for nicer logging.
+   * @param {Record<string, unknown>} obj
+   * @param {string} objectName
+   */
+  static assertNoExcessProperties(obj, objectName) {
+    const invalidKeys = Object.keys(obj);
+    if (invalidKeys.length > 0) {
+      const keys = invalidKeys.join(', ');
+      throw new Error(`${objectName} has unrecognized properties: [${keys}]`);
+    }
+  }
+
+  /**
+   * @param {LH.Budget.ResourceBudget} resourceBudget
+   * @return {LH.Budget.ResourceBudget}
+   */
+  static validateResourceBudget(resourceBudget) {
+    const {resourceType, budget, ...invalidRest} = resourceBudget;
+    Budget.assertNoExcessProperties(invalidRest, 'Resource Budget');
+
+    const validResourceTypes = [
+      'total',
+      'document',
+      'script',
+      'stylesheet',
+      'image',
+      'media',
+      'font',
+      'other',
+      'third-party',
+    ];
+    if (!validResourceTypes.includes(resourceBudget.resourceType)) {
+      throw new Error(`Invalid resource type: ${resourceBudget.resourceType}. \n` +
+        `Valid resource types are: ${ validResourceTypes.join(', ') }`);
+    }
+    if (isNaN(resourceBudget.budget)) {
+      throw new Error('Invalid budget: ${resourceBudget.budget}');
+    }
+    return {
+      resourceType,
+      budget,
+    };
+  }
+
+  /**
+   * @param {LH.Budget.TimingBudget} timingBudget
+   * @return {LH.Budget.TimingBudget}
+   */
+  static validateTimingBudget(timingBudget) {
+    const {metric, budget, tolerance, ...invalidRest} = timingBudget;
+    Budget.assertNoExcessProperties(invalidRest, 'Timing Budget');
+
+    const validTimingMetrics = [
+      'first-contentful-paint',
+      'first-cpu-idle',
+      'interactive',
+      'first-meaningful-paint',
+      'estimated-input-latency',
+    ];
+    if (!validTimingMetrics.includes(timingBudget.metric)) {
+      throw new Error(`Invalid timing metric: ${timingBudget.metric}. \n` +
+        `Valid timing metrics are: ${validTimingMetrics.join(', ')}`);
+    }
+    if (isNaN(timingBudget.budget)) {
+      throw new Error('Invalid budget: ${timingBudget.budget}');
+    }
+    if (timingBudget.tolerance !== undefined && isNaN(timingBudget.tolerance)) {
+      throw new Error('Invalid tolerance: ${timingBudget.tolerance}');
+    }
+    return {
+      metric,
+      budget,
+      tolerance,
+    };
+  }
+
+  /**
+   * More info on the Budget format:
+   * https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
+   * @param {Array<LH.Budget>} budgetArr
+   * @return {Array<LH.Budget>}
+   */
+  static initializeBudget(budgetArr) {
+    /** @type {Array<LH.Budget>} */
+    const budgets = [];
+
+    budgetArr.forEach((b) => {
+      /** @type {LH.Budget} */
+      const budget = {};
+
+      const {resourceSizes, resourceCounts, timings, ...invalidRest} = b;
+      Budget.assertNoExcessProperties(invalidRest, 'Budget');
+
+      if (b.resourceSizes !== undefined) {
+        budget.resourceSizes = b.resourceSizes.map((r) => {
+          return Budget.validateResourceBudget(r);
+        });
+      }
+
+      if (b.resourceCounts !== undefined) {
+        budget.resourceCounts = b.resourceCounts.map((r) => {
+          return Budget.validateResourceBudget(r);
+        });
+      }
+
+      if (b.timings !== undefined) {
+        budget.timings = b.timings.map((t) => {
+          return Budget.validateTimingBudget(t);
+        });
+      }
+      budgets.push({
+        resourceSizes,
+        resourceCounts,
+        timings,
+      });
+    });
+    return budgets;
+  }
+}
+
+module.exports = Budget;
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index b7fb5f7b9..7b26f588a 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -17,6 +17,7 @@ const path = require('path');
 const Audit = require('../audits/audit.js');
 const Runner = require('../runner.js');
 const ConfigPlugin = require('./config-plugin.js');
+const Budget = require('./budget.js');
 
 /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */
 /** @typedef {InstanceType<GathererConstructor>} Gatherer */
@@ -495,7 +496,7 @@ class Config {
   }
 
   /**
-   * @param {LH.Config.SettingsJson=} settingsJson
+   * @param {LH.SharedFlagsSettings=} settingsJson
    * @param {LH.Flags=} flags
    * @return {LH.Config.Settings}
    */
@@ -512,6 +513,9 @@ class Config {
     // Override any applicable settings with CLI flags
     const settingsWithFlags = merge(settingWithDefaults || {}, cleanFlagsForSettings(flags), true);
 
+    if (settingsWithFlags.budgets) {
+      settingsWithFlags.budgets = Budget.initializeBudget(settingsWithFlags.budgets);
+    }
     // Locale is special and comes only from flags/settings/lookupLocale.
     settingsWithFlags.locale = locale;
 
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index 331975751..9f5ce283b 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -55,6 +55,7 @@ const defaultSettings = {
 
   // the following settings have no defaults but we still want ensure that `key in settings`
   // in config will work in a typechecked way
+  budgets: null,
   locale: 'en-US', // actual default determined by Config using lib/i18n
   blockedUrlPatterns: null,
   additionalTraceCategories: null,
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 1cbbf12ce..1504607bc 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -234,6 +234,7 @@ class Runner {
         gatherMode: undefined,
         auditMode: undefined,
         output: undefined,
+        budgets: undefined,
       };
       const normalizedGatherSettings = Object.assign({}, artifacts.settings, overrides);
       const normalizedAuditSettings = Object.assign({}, settings, overrides);
diff --git a/lighthouse-core/scripts/update-report-fixtures.js b/lighthouse-core/scripts/update-report-fixtures.js
index 8aabdc464..39b8b689b 100644
--- a/lighthouse-core/scripts/update-report-fixtures.js
+++ b/lighthouse-core/scripts/update-report-fixtures.js
@@ -25,7 +25,12 @@ async function update() {
   }));
 
   const url = `http://localhost:${port}/dobetterweb/dbw_tester.html`;
-  const flags = cliFlags.getFlags(`--gather-mode=lighthouse-core/test/results/artifacts ${url}`);
+  const rawFlags = [
+    '--gather-mode=lighthouse-core/test/results/artifacts',
+    '--throttling-method=devtools',
+    url,
+  ].join(' ');
+  const flags = cliFlags.getFlags(rawFlags);
   await cli.runLighthouse(url, flags, undefined);
   await new Promise(res => server.close(res));
 }
diff --git a/package.json b/package.json
index f97637f73..a08836112 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
     "type-check": "tsc -p . && tsc -p lighthouse-viewer/",
     "i18n:checks": "./lighthouse-core/scripts/i18n/assert-strings-collected.sh",
     "i18n:collect-strings": "node lighthouse-core/scripts/i18n/collect-strings.js",
-    "update:sample-artifacts": "node lighthouse-core/scripts/update-report-fixtures.js -G",
+    "update:sample-artifacts": "node lighthouse-core/scripts/update-report-fixtures.js",
     "update:sample-json": "yarn i18n:collect-strings && node ./lighthouse-cli -A=./lighthouse-core/test/results/artifacts --throttling-method=devtools --output=json --output-path=./lighthouse-core/test/results/sample_v2.json && node lighthouse-core/scripts/cleanup-LHR-for-diff.js ./lighthouse-core/test/results/sample_v2.json --only-remove-timing && yarn compile-proto && yarn build-proto-roundtrip",
     "diff:sample-json": "yarn i18n:checks && bash lighthouse-core/scripts/assert-golden-lhr-unchanged.sh",
     "ultradumbBenchmark": "./lighthouse-core/scripts/benchmark.js",
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index 76e9dfc90..1216b94dd 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -161,6 +161,9 @@ message LighthouseResult {
       string entry_type = 2;
       google.protobuf.DoubleValue start_time = 3;
       google.protobuf.DoubleValue duration = 4;
+
+      // Whether timing entry was collected during artifact gathering.
+      bool gather = 5;
     }
 
     // The total duration of Lighthouse's run
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3b4f77214..d7fecdafd 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3652,11 +3652,10 @@
             "warningAuditsGroupTitle": "Passed audits but with warnings", 
             "warningHeader": "Warnings: "
         }
-    },
+    }, 
     "lighthouseVersion": "4.3.0", 
     "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html", 
-    "runWarnings": [],
-    "stackPacks": [],
+    "runWarnings": [], 
     "timing": {
         "entries": [
             {
diff --git a/types/budget.d.ts b/types/budget.d.ts
new file mode 100644
index 000000000..b1da9e8de
--- /dev/null
+++ b/types/budget.d.ts
@@ -0,0 +1,49 @@
+/**
+ * @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.
+ */
+
+declare global {
+  module LH {
+    /**
+     * The performance budget interface.
+     * More info: https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
+     */
+    export interface Budget {
+      /** Budgets based on resource count. */
+      resourceCounts?: Array<Budget.ResourceBudget>;
+      /** Budgets based on resource size. */
+      resourceSizes?: Array<Budget.ResourceBudget>;
+      /** Budgets based on timing metrics. */
+      timings?: Array<Budget.TimingBudget> ;
+    }
+
+    module Budget {
+      export interface ResourceBudget {
+        /** The resource type that a budget applies to. */
+        resourceType: ResourceType;
+        /** Budget for resource. Depending on context, this is either the count or size (KB) of a resource. */
+        budget: number;
+      }
+
+      export interface TimingBudget {
+        /** The type of timing metric. */
+        metric: TimingMetric;
+        /** Budget for timing measurement, in milliseconds. */
+        budget: number;
+        /** Tolerance, i.e. buffer, to apply to a timing budget. Units: milliseconds. */
+        tolerance?: number;
+      }
+
+      /** Supported timing metrics. */
+      export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'estimated-input-latency';
+
+      /** Supported resource types. */
+      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other';
+    }
+  }
+}
+
+// empty export to keep file a module
+export {}
diff --git a/types/config.d.ts b/types/config.d.ts
index 3ba80f0c7..88c4c89a5 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -15,7 +15,7 @@ declare global {
        */
       export interface Json {
         extends?: 'lighthouse:default' | 'lighthouse:full' | string | boolean;
-        settings?: SettingsJson;
+        settings?: SharedFlagsSettings;
         passes?: PassJson[] | null;
         audits?: Config.AuditJson[] | null;
         categories?: Record<string, CategoryJson> | null;
@@ -23,10 +23,6 @@ declare global {
         plugins?: Array<string>,
       }
 
-      export interface SettingsJson extends SharedFlagsSettings {
-        extraHeaders?: Crdp.Network.Headers | null;
-      }
-
       export interface PassJson {
         passName: string;
         recordTrace?: boolean;
@@ -80,7 +76,7 @@ declare global {
         group?: string;
       }
 
-      export interface Settings extends Required<SettingsJson> {
+      export interface Settings extends Required<SharedFlagsSettings> {
         throttling: Required<ThrottlingSettings>;
       }
 
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 8de82bbb0..0b7780e13 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -119,6 +119,8 @@ declare global {
       channel?: string
       /** Precomputed lantern estimates to use instead of observed analysis. */
       precomputedLanternData?: PrecomputedLanternData | null;
+      /** The budget.json object for LightWallet. */
+      budgets?: Array<Budget> | null;
     }
 
     /**
@@ -169,6 +171,8 @@ declare global {
       precomputedLanternDataPath?: string;
       /** Path to the file where precomputed lantern data should be written to. */
       lanternDataOutputPath?: string;
+      /** Path to the budget.json file for LightWallet. */
+      budgetPath?: string | null;
 
       // The following are given defaults in cli-flags, so are not optional like in Flags or SharedFlagsSettings.
       output: OutputMode[];
diff --git a/types/lhr.d.ts b/types/lhr.d.ts
index f78312b5c..28c60b9ed 100644
--- a/types/lhr.d.ts
+++ b/types/lhr.d.ts
@@ -61,7 +61,7 @@ declare global {
       /** The record of all formatted string locations in the LHR and their corresponding source values. */
       i18n: {rendererFormattedStrings: I18NRendererStrings, icuMessagePaths: I18NMessages};
       /** An array containing the result of all stack packs. */
-      stackPacks: Result.StackPack[];
+      stackPacks?: Result.StackPack[];
     }
 
     // Result namespace


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 593c3c48d..dd4636eb9 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1198,6 +1198,7 @@ Object {
     "additionalTraceCategories": null,
     "auditMode": false,
     "blockedUrlPatterns": null,
+    "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
@@ -1327,6 +1328,7 @@ Object {
     "additionalTraceCategories": null,
     "auditMode": true,
     "blockedUrlPatterns": null,
+    "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
diff --git a/lighthouse-cli/test/cli/bin-test.js b/lighthouse-cli/test/cli/bin-test.js
index 713d0172c..d6b3ce96b 100644
--- a/lighthouse-cli/test/cli/bin-test.js
+++ b/lighthouse-cli/test/cli/bin-test.js
@@ -99,6 +99,17 @@ describe('CLI bin', function() {
     });
   });
 
+  describe('budget', () => {
+    it('should load the config from the path', async () => {
+      const budgetPath = '../../../lighthouse-core/test/fixtures/simple-budget.json';
+      cliFlags = {...cliFlags, budgetPath: require.resolve(budgetPath)};
+      const budgetFile = require(budgetPath);
+      await bin.begin();
+
+      expect(getRunLighthouseArgs()[1].budgets).toEqual(budgetFile);
+    });
+  });
+
   describe('logging', () => {
     it('should have info by default', async () => {
       await bin.begin();
diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js
new file mode 100644
index 000000000..7a56d587b
--- /dev/null
+++ b/lighthouse-core/test/config/budget-test.js
@@ -0,0 +1,128 @@
+/**
+ * @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 Budget = require('../../config/budget.js');
+const assert = require('assert');
+/* eslint-env jest */
+
+describe('Budget', () => {
+  let budget;
+  beforeEach(() => {
+    budget = [
+      {
+        resourceSizes: [
+          {
+            resourceType: 'script',
+            budget: 123,
+          },
+          {
+            resourceType: 'image',
+            budget: 456,
+          },
+        ],
+        resourceCounts: [
+          {
+            resourceType: 'total',
+            budget: 100,
+          },
+          {
+            resourceType: 'third-party',
+            budget: 10,
+          },
+        ],
+        timings: [
+          {
+            metric: 'interactive',
+            budget: 2000,
+            tolerance: 1000,
+          },
+          {
+            metric: 'first-contentful-paint',
+            budget: 1000,
+            tolerance: 500,
+          },
+        ],
+      },
+      {
+        resourceSizes: [
+          {
+            resourceType: 'script',
+            budget: 1000,
+          },
+        ],
+      },
+    ];
+  });
+
+  it('initializes correctly', () => {
+    const budgets = Budget.initializeBudget(budget);
+    assert.equal(budgets.length, 2);
+
+    // Sets resources sizes correctly
+    assert.equal(budgets[0].resourceSizes.length, 2);
+    assert.equal(budgets[0].resourceSizes[0].resourceType, 'script');
+    assert.equal(budgets[0].resourceSizes[0].budget, 123);
+
+    // Sets resource counts correctly
+    assert.equal(budgets[0].resourceCounts.length, 2);
+    assert.equal(budgets[0].resourceCounts[0].resourceType, 'total');
+    assert.equal(budgets[0].resourceCounts[0].budget, 100);
+
+    // Sets timings correctly
+    assert.equal(budgets[0].timings.length, 2);
+    assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint');
+    assert.equal(budgets[0].timings[1].budget, 1000);
+    assert.equal(budgets[0].timings[1].tolerance, 500);
+
+    // Does not set unsupplied budgets
+    assert.equal(budgets[1].timings, null);
+  });
+
+  it('throws error if an unsupported budget property is used', () => {
+    budget[0].sizes = [];
+    assert.throws(_ => Budget.initializeBudget(budget), /[sizes]/);
+  });
+
+  describe('resource budget validation', () => {
+    it('throws when an invalid resource type is supplied', () => {
+      budget[0].resourceSizes[0].resourceType = 'movies';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid resource type/);
+    });
+
+    it('throws when an invalid budget is supplied', () => {
+      budget[0].resourceSizes[0].budget = '100 MB';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
+    });
+
+    it('throws when an invalid property is supplied', () => {
+      budget[0].resourceSizes[0].browser = 'Chrome';
+      assert.throws(_ => Budget.initializeBudget(budget), /[browser]/);
+    });
+  });
+
+  describe('timing budget validation', () => {
+    it('throws when an invalid metric is supplied', () => {
+      budget[0].timings[0].metric = 'lastMeaningfulPaint';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid timing metric/);
+    });
+
+    it('throws when an invalid budget is supplied', () => {
+      budget[0].timings[0].budget = '100KB';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
+    });
+
+    it('throws when an invalid tolerance is supplied', () => {
+      budget[0].timings[0].tolerance = '100ms';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance/);
+    });
+
+    it('throws when an invalid property is supplied', () => {
+      budget[0].timings[0].device = 'Phone';
+      assert.throws(_ => Budget.initializeBudget(budget), /[device]/);
+    });
+  });
+});
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index efeefb736..c3c29045b 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -741,6 +741,30 @@ describe('Config', () => {
       assert.strictEqual(config.categories['lighthouse-plugin-simple'].title, 'Simple');
     });
 
+    describe('budget setting', () => {
+      it('should be initialized', () => {
+        const configJson = {
+          settings: {
+            budgets: [{
+              resourceCounts: [{
+                resourceType: 'image',
+                budget: 500,
+              }],
+            }],
+          },
+        };
+        const config = new Config(configJson);
+        assert.equal(config.settings.budgets[0].resourceCounts.length, 1);
+        assert.equal(config.settings.budgets[0].resourceCounts[0].resourceType, 'image');
+        assert.equal(config.settings.budgets[0].resourceCounts[0].budget, 500);
+      });
+
+      it('should throw when provided an invalid budget', () => {
+        assert.throws(() => new Config({settings: {budgets: ['invalid123']}}),
+          /Budget has unrecognized properties/);
+      });
+    });
+
     it('should load plugins from the config and from passed-in flags', () => {
       const baseConfigJson = {
         audits: ['installable-manifest'],
diff --git a/lighthouse-core/test/fixtures/simple-budget.json b/lighthouse-core/test/fixtures/simple-budget.json
new file mode 100644
index 000000000..e8f3030e6
--- /dev/null
+++ b/lighthouse-core/test/fixtures/simple-budget.json
@@ -0,0 +1,45 @@
+[
+    {
+        "resourceSizes": [
+            {
+                "resourceType": "script",
+                "budget": 125
+            },
+            {
+                "resourceType": "image",
+                "budget": 300
+            },
+            {
+                "resourceType": "total",
+                "budget": 500
+            },
+            {
+                "resourceType": "third-party",
+                "budget": 200
+            }
+        ],
+        "timings": [
+            {
+                "metric": "interactive",
+                "budget": 5000,
+                "tolerance": 1000
+            },
+            {
+                "metric": "first-meaningful-paint",
+                "budget": 2000
+            }
+        ]
+    },
+    {
+        "resourceCounts": [
+            {
+                "resourceType": "total",
+                "budget": 100
+            },
+            {
+                "resourceType": "third-party",
+                "budget": 0
+            }
+        ]
+    }
+]
diff --git a/lighthouse-core/test/report/proto-test.js b/lighthouse-core/test/report/proto-test.js
index a90811ee7..8282ffbf3 100644
--- a/lighthouse-core/test/report/proto-test.js
+++ b/lighthouse-core/test/report/proto-test.js
@@ -45,12 +45,19 @@ describe('round trip JSON comparison subsets', () => {
   });
 
   it('has the same top level values', () => {
+    // Don't test all top level properties that are objects.
     Object.keys(sampleJson).forEach(audit => {
       if (typeof sampleJson[audit] === 'object' && !Array.isArray(sampleJson[audit])) {
         delete sampleJson[audit];
       }
     });
 
+    // Properties set to their type's default value will be omitted in the roundTripJson.
+    // For an explicit list of properties, remove sampleJson values if set to a default.
+    if (Array.isArray(sampleJson.stackPacks) && sampleJson.stackPacks.length === 0) {
+      delete sampleJson.stackPacks;
+    }
+
     expect(roundTripJson).toMatchObject(sampleJson);
   });
 
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 0aef7b229..c5a3d0894 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -29,6 +29,33 @@
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
     "channel": "cli",
+    "budgets": [
+      {
+        "resourceSizes": [
+          {
+            "resourceType": "script",
+            "budget": 125
+          },
+          {
+            "resourceType": "total",
+            "budget": 500
+          }
+        ],
+        "timings": [
+          {
+            "metric": "interactive",
+            "budget": 5000,
+            "tolerance": 1000
+          }
+        ],
+         "resourceCounts": [
+          {
+           "resourceType": "third-party",
+           "budget": 0
+          }
+        ]
+      }
+    ],
     "locale": "en-US",
     "blockedUrlPatterns": null,
     "additionalTraceCategories": null,
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index fe569c181..a413fe98d 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2962,6 +2962,7 @@
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
     "channel": "cli",
+    "budgets": null,
     "locale": "en-US",
     "blockedUrlPatterns": null,
     "additionalTraceCategories": null,