Repository: GoogleChrome/lighthouse
Pull request #8591 (2019-04-24 19:19:41)

report(redesign): various whitespace adjustments (DRAFT)
#8185

patrickhulce: we don't get anymore top margin now though between the category header should we bump that a tad?

![image](https://user-images.githubusercontent.com/2301202/56688153-90fe6300-669d-11e9-995c-794a75d0b9fe.png)

Hoten: Ok, I tried to adjust that @patrickhulce, and some other things that didn't match re: section spacing with the report, and it looks closer now ?

Super hard to split up changes that affect spacing (padding, margin of various components, font sizes, etc). I want to pull up the design and the impl side by side, and make pixel-perfect adjustments. but if the font size goes in one PR, and spacing for gauges in one, and margins for sections in another ... well, my brain goes kaploot.
patrickhulce: > I want to pull up the design and the impl side by side, and make pixel-perfect adjustments.

Why don't we just do that then? :) That's always the strategy that makes more sense to me personally, sit down with a designer and make all the adjustments at once.

Patch:
diff --git a/.travis.yml b/.travis.yml
index a69b1aa6d..eb9afebd5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -26,6 +26,7 @@ before_script:
   # see comment above about puppeteer
   - export CHROME_PATH="$(which google-chrome-stable)"
   - sh -e /etc/init.d/xvfb start
+  - google-chrome-stable --version
   - yarn build-all
 script:
   - yarn bundlesize
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/docs/recipes/custom-audit/searchable-audit.js b/docs/recipes/custom-audit/searchable-audit.js
index 86ecdd2cf..18fe22656 100644
--- a/docs/recipes/custom-audit/searchable-audit.js
+++ b/docs/recipes/custom-audit/searchable-audit.js
@@ -36,7 +36,7 @@ class LoadAudit extends Audit {
     const belowThreshold = loadMetrics.searchableTime <= MAX_SEARCHABLE_TIME;
 
     return {
-      rawValue: loadMetrics.searchableTime,
+      numericValue: loadMetrics.searchableTime,
       // Cast true/false to 1/0
       score: Number(belowThreshold),
     };
diff --git a/docs/recipes/lighthouse-plugin-example/audits/preload-as.js b/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
index 48d8385ed..892aba6c3 100644
--- a/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
+++ b/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
@@ -37,7 +37,7 @@ class PreloadAsAudit extends Audit {
     const passed = noAsLinks.length === 0;
 
     return {
-      rawValue: passed,
+      score: passed ? 1 : 0,
       displayValue: `Found ${noAsLinks.length} preload requests with missing \`as\` attributes`,
     };
   }
diff --git a/docs/releasing.md b/docs/releasing.md
index f61541bde..a1a026f0e 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -31,73 +31,22 @@ We follow [semver](https://semver.org/) versioning semantics (`vMajor.Minor.Patc
 ## Release Process
 
 ```sh
-# use a custom lighthouse-pristine checkout to make sure your dev files aren't involved.
-
-# * Install the latest.*
-yarn
-
-# * Bump it *
-yarn version --no-git-tag-version
-# manually bump extension v in clients/extension/manifest.json
-yarn update:sample-json
-
-# * Build it. This also builds the cli, extension, and viewer. *
-yarn build-all
-
-# * Test err'thing *
-echo "Test the CLI."
-yarn start "https://example.com" --view
-yarn smoke
-
-echo "Test the extension"
-# ...
-
-echo "Test a fresh local install"
-# (starting from lighthouse-pristine root...)
-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 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
-
-echo "Test the lighthouse-viewer build"
-# Manual test for now:
-# Start a server in dist/viewer/ and open the page in a tab. You should see the viewer.
-# Drop in a results.json or paste an existing gist url (e.g. https://gist.github.com/ebidel/b9fd478b5f40bf5fab174439dc18f83a).
-# Check for errors!
-cd dist/viewer ; python -m SimpleHTTPServer
-# go to http://localhost:8000/
-
-# * Update changelog *
-git fetch --tags
-yarn changelog
-# add new contributors, e.g. from git shortlog -s -e -n v2.3.0..HEAD
-#    and https://github.com/GoogleChrome/lighthouse/graphs/contributors
-echo "Edit the changelog for readability and brevity"
-
-# * Put up the PR *
-echo "Branch and commit the version bump."
-git checkout -b bumpv240
-git commit -am "2.4.0"
-echo "Generate a PR and get it merged."
-
-echo "Once it's merged, pull master and tag the (squashed) commit"
-git tag -a v2.4.0 -m "v2.4.0"
-git push --tags
-
-
-# * Deploy-time *
-echo "Rebuild extension and viewer to get the latest, tagged master commit"
-yarn build-all;
-
-# zip the extension files
+# Run the tests
+bash ./lighthouse-core/scripts/release/release-test.sh
+# Prepare the commit, replace x.x.x with the desired version
+bash ./lighthouse-core/scripts/release/release-prepare-commit.sh x.x.x
+
+# Open the PR and await merge...
+echo "It's been merged! 🎉"
+
+# Run the tests again :)
+bash ./lighthouse-core/scripts/release/release-test.sh
+# Package everything for publishing
+bash ./lighthouse-core/scripts/release/release-prepare-package.sh
+
+# Upload the extension
 node build/build-extension.js package; cd dist/extension-package/
-echo "Go here: https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk "
+open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
 echo "Upload the package zip to CWS dev dashboard"
 # Be in lighthouse-extension-owners group
 # Open <https://chrome.google.com/webstore/developer/dashboard>
@@ -106,12 +55,11 @@ echo "Upload the package zip to CWS dev dashboard"
 # Select `lighthouse-4.X.X.zip`
 # _Publish_ at the bottom
 
-echo "Verify the npm package won't include unncessary files"
-npm pack --dry-run
-npx pkgfiles
-
-echo "ship it"
+# Make sure you're in the Lighthouse pristine repo we just tested.
+cd ../lighthouse-pristine
+# Publish to NPM
 npm publish
+# Publish viewer
 yarn deploy-viewer
 
 # * Tell the world!!! *
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/audits/audit.js b/lighthouse-core/audits/audit.js
index 3c9230a44..5daa69c5c 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -270,7 +270,7 @@ class Audit {
 
       score,
       scoreDisplayMode,
-      numericValue: product.rawValue,
+      numericValue: product.numericValue,
 
       displayValue: product.displayValue,
       explanation: product.explanation,
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index c2cc645b6..c5e178fef 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -179,7 +179,7 @@ class BootupTime extends Audit {
 
     return {
       score,
-      rawValue: totalBootupTime,
+      numericValue: totalBootupTime,
       displayValue: totalBootupTime > 0 ?
         str_(i18n.UIStrings.seconds, {timeInMs: totalBootupTime}) : '',
       details,
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 9c99a78fc..634f44851 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -208,7 +208,7 @@ class UnusedBytes extends Audit {
       explanation: result.explanation,
       warnings: result.warnings,
       displayValue,
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       score: UnusedBytes.scoreForWastedMs(wastedMs),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 5e51c98a0..bcd9de011 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -224,7 +224,7 @@ class RenderBlockingResources extends Audit {
     return {
       displayValue,
       score: ByteEfficiencyAudit.scoreForWastedMs(wastedMs),
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       details,
     };
   }
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index 542b726e8..615f6ed99 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -99,7 +99,7 @@ class TotalByteWeight extends ByteEfficiencyAudit {
 
     return {
       score,
-      rawValue: totalBytes,
+      numericValue: totalBytes,
       displayValue: str_(UIStrings.displayValue, {totalBytes}),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 692fdc384..0d4738055 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -284,7 +284,7 @@ class CacheHeaders extends Audit {
 
       return {
         score,
-        rawValue: totalWastedBytes,
+        numericValue: totalWastedBytes,
         displayValue: str_(UIStrings.displayValue, {itemCount: results.length}),
         extendedInfo: {
           value: {
diff --git a/lighthouse-core/audits/diagnostics.js b/lighthouse-core/audits/diagnostics.js
index 2a1fb54d4..ac52685d5 100644
--- a/lighthouse-core/audits/diagnostics.js
+++ b/lighthouse-core/audits/diagnostics.js
@@ -66,7 +66,6 @@ class Diagnostics extends Audit {
 
     return {
       score: 1,
-      rawValue: 1,
       details: {
         type: 'debugdata',
         // TODO: Consider not nesting diagnostics under `items`.
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index e02e9869f..c3cffd61f 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -135,7 +135,7 @@ class DOMSize extends Audit {
 
     return {
       score,
-      rawValue: stats.totalBodyElements,
+      numericValue: stats.totalBodyElements,
       displayValue: str_(UIStrings.displayValue, {itemCount: stats.totalBodyElements}),
       extendedInfo: {
         value: items,
diff --git a/lighthouse-core/audits/errors-in-console.js b/lighthouse-core/audits/errors-in-console.js
index 1df3399a4..079c6f898 100644
--- a/lighthouse-core/audits/errors-in-console.js
+++ b/lighthouse-core/audits/errors-in-console.js
@@ -71,7 +71,7 @@ class ErrorLogs extends Audit {
 
     return {
       score: Number(numErrors === 0),
-      rawValue: numErrors,
+      numericValue: numErrors,
       details,
     };
   }
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index e05fd1b68..f6460c28e 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Audit = require('./audit');
-const URL = require('../lib/url-shim').URL;
+const URL = require('../lib/url-shim');
 const PASSING_FONT_DISPLAY_REGEX = /^(block|fallback|optional|swap)$/;
 const CSS_URL_REGEX = /url\((.*?)\)/;
 const CSS_URL_GLOBAL_REGEX = new RegExp(CSS_URL_REGEX, 'g');
@@ -82,11 +82,11 @@ class FontDisplay extends Audit {
 
             return s;
           });
-
         // Convert the relative CSS URL to an absolute URL and add it to the passing set
         for (const relativeURL of relativeURLs) {
           try {
-            const relativeRoot = stylesheet.header.sourceURL || artifacts.URL.finalUrl;
+            const relativeRoot = URL.isValid(stylesheet.header.sourceURL) ?
+              stylesheet.header.sourceURL : artifacts.URL.finalUrl;
             const absoluteURL = new URL(relativeURL, relativeRoot);
             passingURLs.add(absoluteURL.href);
           } catch (err) {
diff --git a/lighthouse-core/audits/load-fast-enough-for-pwa.js b/lighthouse-core/audits/load-fast-enough-for-pwa.js
index 102fd9796..cbc226b74 100644
--- a/lighthouse-core/audits/load-fast-enough-for-pwa.js
+++ b/lighthouse-core/audits/load-fast-enough-for-pwa.js
@@ -95,7 +95,7 @@ class LoadFastEnough4Pwa extends Audit {
       score,
       displayValue,
       explanation,
-      rawValue: tti.timing,
+      numericValue: tti.timing,
     };
   }
 }
diff --git a/lighthouse-core/audits/main-thread-tasks.js b/lighthouse-core/audits/main-thread-tasks.js
index 1399e6248..e3265f8bb 100644
--- a/lighthouse-core/audits/main-thread-tasks.js
+++ b/lighthouse-core/audits/main-thread-tasks.js
@@ -51,7 +51,7 @@ class MainThreadTasks extends Audit {
 
     return {
       score: 1,
-      rawValue: results.length,
+      numericValue: results.length,
       details: tableDetails,
     };
   }
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index 9325693ae..1865613c3 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -122,7 +122,7 @@ class MainThreadWorkBreakdown extends Audit {
 
     return {
       score,
-      rawValue: totalExecutionTime,
+      numericValue: totalExecutionTime,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: totalExecutionTime}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 8818812c8..2a7db3068 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -117,7 +117,7 @@ class Metrics extends Audit {
 
     return {
       score: 1,
-      rawValue: (interactive && interactive.timing) || 0,
+      numericValue: (interactive && interactive.timing) || 0,
       details,
     };
   }
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index 5c9128814..38efde2b3 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -66,7 +66,7 @@ class EstimatedInputLatency extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
index 1e9413fb6..8742c2752 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
@@ -55,7 +55,7 @@ class FirstContentfulPaint3G extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: `${metricResult.timing}\xa0ms`,
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint.js b/lighthouse-core/audits/metrics/first-contentful-paint.js
index 5da25f0d9..aa8753738 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint.js
@@ -63,7 +63,7 @@ class FirstContentfulPaint extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-cpu-idle.js b/lighthouse-core/audits/metrics/first-cpu-idle.js
index fe2e3d586..6557ba152 100644
--- a/lighthouse-core/audits/metrics/first-cpu-idle.js
+++ b/lighthouse-core/audits/metrics/first-cpu-idle.js
@@ -67,7 +67,7 @@ class FirstCPUIdle extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-meaningful-paint.js b/lighthouse-core/audits/metrics/first-meaningful-paint.js
index 6272884ae..fe83fc0f7 100644
--- a/lighthouse-core/audits/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/audits/metrics/first-meaningful-paint.js
@@ -66,7 +66,7 @@ class FirstMeaningfulPaint extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index df3891628..d14628444 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -78,7 +78,7 @@ class InteractiveMetric extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: timeInMs,
+      numericValue: timeInMs,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs}),
       extendedInfo: {
         value: extendedInfo,
diff --git a/lighthouse-core/audits/metrics/max-potential-fid.js b/lighthouse-core/audits/metrics/max-potential-fid.js
index 29bbbfc43..3a81771bf 100644
--- a/lighthouse-core/audits/metrics/max-potential-fid.js
+++ b/lighthouse-core/audits/metrics/max-potential-fid.js
@@ -66,7 +66,7 @@ class MaxPotentialFID extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/speed-index.js b/lighthouse-core/audits/metrics/speed-index.js
index 54ad2de7b..d8cb2f193 100644
--- a/lighthouse-core/audits/metrics/speed-index.js
+++ b/lighthouse-core/audits/metrics/speed-index.js
@@ -65,7 +65,7 @@ class SpeedIndex extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/network-requests.js b/lighthouse-core/audits/network-requests.js
index 2a8235220..d15f8e28b 100644
--- a/lighthouse-core/audits/network-requests.js
+++ b/lighthouse-core/audits/network-requests.js
@@ -90,7 +90,7 @@ class NetworkRequests extends Audit {
 
       return {
         score: 1,
-        rawValue: results.length,
+        numericValue: results.length,
         details: tableDetails,
       };
     });
diff --git a/lighthouse-core/audits/network-rtt.js b/lighthouse-core/audits/network-rtt.js
index 49986ca85..13f074fe2 100644
--- a/lighthouse-core/audits/network-rtt.js
+++ b/lighthouse-core/audits/network-rtt.js
@@ -69,7 +69,7 @@ class NetworkRTT extends Audit {
 
     return {
       score: 1,
-      rawValue: maxRtt,
+      numericValue: maxRtt,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxRtt}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/network-server-latency.js b/lighthouse-core/audits/network-server-latency.js
index 1f3b37853..a3067a58d 100644
--- a/lighthouse-core/audits/network-server-latency.js
+++ b/lighthouse-core/audits/network-server-latency.js
@@ -68,7 +68,7 @@ class NetworkServerLatency extends Audit {
 
     return {
       score: Math.max(1 - (maxLatency / 500), 0),
-      rawValue: maxLatency,
+      numericValue: maxLatency,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxLatency}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js
index 369c1c07e..0c892df96 100644
--- a/lighthouse-core/audits/predictive-perf.js
+++ b/lighthouse-core/audits/predictive-perf.js
@@ -88,7 +88,7 @@ class PredictivePerf extends Audit {
 
     return {
       score,
-      rawValue: values.roughEstimateOfTTI,
+      numericValue: values.roughEstimateOfTTI,
       displayValue: Util.formatMilliseconds(values.roughEstimateOfTTI),
       details: {
         type: 'debugdata',
diff --git a/lighthouse-core/audits/redirects.js b/lighthouse-core/audits/redirects.js
index a2b1c7456..9e40a18a1 100644
--- a/lighthouse-core/audits/redirects.js
+++ b/lighthouse-core/audits/redirects.js
@@ -108,7 +108,7 @@ class Redirects extends Audit {
     return {
       // We award a passing grade if you only have 1 redirect
       score: redirectRequests.length <= 2 ? 1 : UnusedBytes.scoreForWastedMs(totalWastedMs),
-      rawValue: totalWastedMs,
+      numericValue: totalWastedMs,
       displayValue: totalWastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: totalWastedMs}) :
         '',
diff --git a/lighthouse-core/audits/time-to-first-byte.js b/lighthouse-core/audits/time-to-first-byte.js
index ec6610ff2..37982b6e4 100644
--- a/lighthouse-core/audits/time-to-first-byte.js
+++ b/lighthouse-core/audits/time-to-first-byte.js
@@ -69,7 +69,7 @@ class TTFBMetric extends Audit {
     };
 
     return {
-      rawValue: ttfb,
+      numericValue: ttfb,
       score: Number(passed),
       displayValue,
       details,
diff --git a/lighthouse-core/audits/uses-rel-preconnect.js b/lighthouse-core/audits/uses-rel-preconnect.js
index e08620b25..8e80cda44 100644
--- a/lighthouse-core/audits/uses-rel-preconnect.js
+++ b/lighthouse-core/audits/uses-rel-preconnect.js
@@ -188,7 +188,7 @@ class UsesRelPreconnectAudit extends Audit {
 
     return {
       score: UnusedBytes.scoreForWastedMs(maxWasted),
-      rawValue: maxWasted,
+      numericValue: maxWasted,
       displayValue: maxWasted ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: maxWasted}) :
         '',
diff --git a/lighthouse-core/audits/uses-rel-preload.js b/lighthouse-core/audits/uses-rel-preload.js
index 671e2ffea..240a6a9ba 100644
--- a/lighthouse-core/audits/uses-rel-preload.js
+++ b/lighthouse-core/audits/uses-rel-preload.js
@@ -222,7 +222,7 @@ class UsesRelPreloadAudit extends Audit {
 
     return {
       score: UnusedBytes.scoreForWastedMs(wastedMs),
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       displayValue: wastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) :
         '',
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/gather/connections/cri.js b/lighthouse-core/gather/connections/cri.js
index 8b47e11b9..8fae2b3c4 100644
--- a/lighthouse-core/gather/connections/cri.js
+++ b/lighthouse-core/gather/connections/cri.js
@@ -108,21 +108,17 @@ class CriConnection extends Connection {
         });
       });
 
-      request.setTimeout(CONNECT_TIMEOUT, () => {
-        request.abort();
-
-        // After aborting, we expect an ECONNRESET error. Ignore.
-        request.on('error', err => {
-          // @ts-ignore - not in @types yet. See https://nodejs.org/api/errors.html#errors_class_systemerror.
-          if (err.code !== 'ECONNRESET') {
-            throw err;
-          }
-        });
+      // This error handler is critical to ensuring Lighthouse exits cleanly even when Chrome crashes.
+      // See https://github.com/GoogleChrome/lighthouse/pull/8583.
+      request.on('error', reject);
 
+      request.setTimeout(CONNECT_TIMEOUT, () => {
         // Reject on error with code specifically indicating timeout in connection setup.
         const err = new LighthouseError(LighthouseError.errors.CRI_TIMEOUT);
         log.error('CriConnection', err.friendlyMessage);
         reject(err);
+
+        request.abort();
       });
     });
   }
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index edda6fb8d..3c37fd85c 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1260,8 +1260,8 @@
     "description": "Error message explaining that Lighthouse couldn't complete because the page has stopped responding to its instructions."
   },
   "lighthouse-core/lib/lh-error.js | pageLoadFailedInsecure": {
-    "message": "The URL you have provided does not have valid security credentials. {securityMessages}",
-    "description": "Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load."
+    "message": "The URL you have provided does not have a valid security certificate. {securityMessages}",
+    "description": "Error message explaining that the security certificate of the page Lighthouse observed was invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load."
   },
   "lighthouse-core/lib/lh-error.js | pageLoadFailedWithDetails": {
     "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})",
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index a349667fb..fdbf06d7a 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -21,8 +21,8 @@ const UIStrings = {
   pageLoadFailedWithStatusCode: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: {statusCode})',
   /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
   pageLoadFailedWithDetails: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})',
-  /** Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load. */
-  pageLoadFailedInsecure: 'The URL you have provided does not have valid security credentials. {securityMessages}',
+  /** Error message explaining that the security certificate of the page Lighthouse observed was invalid, so the URL cannot be accessed. securityMessages will be replaced with one or more strings from the browser explaining what was insecure about the page load. */
+  pageLoadFailedInsecure: 'The URL you have provided does not have a valid security certificate. {securityMessages}',
   /** Error message explaining that Chrome has encountered an error during the Lighthouse run, and that Chrome should be restarted. */
   internalChromeError: 'An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.',
   /** Error message explaining that fetching the resources of the webpage has taken longer than the maximum time. */
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 42d8b7466..8b5dedf66 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -157,6 +157,39 @@ class ReportRenderer {
     return container;
   }
 
+  /**
+   * @param {LH.ReportResult} report
+   * @param {CategoryRenderer} categoryRenderer
+   * @param {Record<string, CategoryRenderer>} specificCategoryRenderers
+   * @return {DocumentFragment[]}
+   */
+  _renderScoreGauges(report, categoryRenderer, specificCategoryRenderers) {
+    // Group gauges in this order: default, pwa, plugins.
+    const defaultGauges = [];
+    const customGauges = []; // PWA.
+    const pluginGauges = [];
+
+    for (const category of report.reportCategories) {
+      const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
+      const categoryGauge = renderer.renderScoreGauge(category, report.categoryGroups || {});
+
+      if (Util.isPluginCategory(category.id)) {
+        pluginGauges.push(categoryGauge);
+      } else if (renderer.renderScoreGauge === categoryRenderer.renderScoreGauge) {
+        // The renderer for default categories is just the default CategoryRenderer.
+        // If the functions are equal, then renderer is an instance of CategoryRenderer.
+        // For example, the PWA category uses PwaCategoryRenderer, which overrides
+        // CategoryRenderer.renderScoreGauge, so it would fail this check and be placed
+        // in the customGauges bucket.
+        defaultGauges.push(categoryGauge);
+      } else {
+        customGauges.push(categoryGauge);
+      }
+    }
+
+    return [...defaultGauges, ...customGauges, ...pluginGauges];
+  }
+
   /**
    * @param {LH.ReportResult} report
    * @return {DocumentFragment}
@@ -223,29 +256,9 @@ class ReportRenderer {
     // }
 
     if (scoreHeader) {
-      // Group gauges in this order: default, pwa, plugins.
-      const defaultGauges = [];
-      const customGauges = []; // PWA.
-      const pluginGauges = [];
-      for (const category of report.reportCategories) {
-        const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
-        const categoryGauge = renderer.renderScoreGauge(category, report.categoryGroups || {});
-
-        if (Util.isPluginCategory(category.id)) {
-          pluginGauges.push(categoryGauge);
-        } else if (renderer.renderScoreGauge === categoryRenderer.renderScoreGauge) {
-          // The renderer for default categories is just the default CategoryRenderer.
-          // If the functions are equal, then renderer is an instance of CategoryRenderer.
-          // For example, the PWA category uses PwaCategoryRenderer, which overrides
-          // CategoryRenderer.renderScoreGauge, so it would fail this check and be placed
-          // in the customGauges bucket.
-          defaultGauges.push(categoryGauge);
-        } else {
-          customGauges.push(categoryGauge);
-        }
-      }
-      scoreHeader.append(...defaultGauges, ...customGauges, ...pluginGauges);
-
+      const scoreGauges =
+        this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers);
+      scoreHeader.append(...scoreGauges);
       const scoreScale = this._dom.cloneTemplate('#tmpl-lh-scorescale', this._templateContext);
       const scoresContainer = this._dom.find('.lh-scores-container', headerContainer);
       scoresContainer.appendChild(scoreHeader);
@@ -255,10 +268,23 @@ class ReportRenderer {
     reportSection.appendChild(this._renderReportFooter(report));
 
     const reportFragment = this._dom.createFragment();
+
     if (!this._dom.isDevTools()) {
       const topbarDocumentFragment = this._renderReportTopbar(report);
       reportFragment.appendChild(topbarDocumentFragment);
     }
+
+    if (scoreHeader && !this._dom.isDevTools()) {
+      const stickyHeader = this._dom.createElement('div', 'lh-sticky-header');
+      this._dom.createChildOf(stickyHeader, 'div', 'lh-highlighter');
+
+      const scoreGauges =
+        this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers);
+      stickyHeader.append(...scoreGauges);
+
+      reportFragment.appendChild(stickyHeader);
+    }
+
     reportFragment.appendChild(headerContainer);
     reportFragment.appendChild(container);
 
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 4d8bdd6a4..62e5e2e8d 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -40,6 +40,14 @@ class ReportUIFeatures {
     this._copyAttempt = false;
     /** @type {HTMLElement} */
     this.exportButton; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this.topbarEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this.scoreScaleEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this.stickyHeaderEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this.highlightEl; // eslint-disable-line no-unused-expressions
 
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
@@ -48,6 +56,7 @@ class ReportUIFeatures {
     this.onKeyDown = this.onKeyDown.bind(this);
     this.printShortCutDetect = this.printShortCutDetect.bind(this);
     this.onChevronClick = this.onChevronClick.bind(this);
+    this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
   }
 
   /**
@@ -61,10 +70,13 @@ class ReportUIFeatures {
     this.json = report;
     this._setupMediaQueryListeners();
     this._setupExportButton();
+    this._setupStickyHeaderElements();
     this._setUpCollapseDetailsAfterPrinting();
     this._resetUIState();
     this._document.addEventListener('keydown', this.printShortCutDetect);
     this._document.addEventListener('copy', this.onCopy);
+    this._document.addEventListener('scroll', this._updateStickyHeaderOnScroll);
+    window.addEventListener('resize', this._updateStickyHeaderOnScroll);
   }
 
   /**
@@ -102,6 +114,13 @@ class ReportUIFeatures {
     dropdown.addEventListener('click', this.onExport);
   }
 
+  _setupStickyHeaderElements() {
+    this.topbarEl = this._dom.find('.lh-topbar', this._document);
+    this.scoreScaleEl = this._dom.find('.lh-scorescale', this._document);
+    this.stickyHeaderEl = this._dom.find('.lh-sticky-header', this._document);
+    this.highlightEl = this._dom.find('.lh-highlighter', this._document);
+  }
+
   /**
    * Handle copy events.
    * @param {ClipboardEvent} e
@@ -380,6 +399,30 @@ class ReportUIFeatures {
     this._document.body.removeChild(a);
     setTimeout(_ => URL.revokeObjectURL(href), 500);
   }
+
+  _updateStickyHeaderOnScroll() {
+    // Show sticky header when the score scale begins to go underneath the topbar.
+    const topbarBottom = this.topbarEl.getBoundingClientRect().bottom;
+    const scoreScaleTop = this.scoreScaleEl.getBoundingClientRect().top;
+    const showStickyHeader = topbarBottom >= scoreScaleTop;
+
+    // Highlight mini gauge when section is in view.
+    // In view = the last category that starts above the middle of the window.
+    const categoryEls = Array.from(this._document.querySelectorAll('.lh-category'));
+    const categoriesAboveTheMiddle =
+      categoryEls.filter(el => el.getBoundingClientRect().top - window.innerHeight / 2 < 0);
+    const highlightIndex =
+      categoriesAboveTheMiddle.length > 0 ? categoriesAboveTheMiddle.length - 1 : 0;
+
+    // Category order matches gauge order in sticky header.
+    const gaugeWrapperEls = this.stickyHeaderEl.querySelectorAll('.lh-gauge__wrapper');
+    const gaugeToHighlight = gaugeWrapperEls[highlightIndex];
+    const offset = gaugeToHighlight.getBoundingClientRect().left + 'px';
+
+    // Mutate at end to avoid layout thrashing.
+    this.stickyHeaderEl.classList.toggle('lh-sticky-header--visible', showStickyHeader);
+    this.highlightEl.style.left = offset;
+  }
 }
 
 if (typeof module !== 'undefined' && module.exports) {
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 9130bab3f..f7c25eb11 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -118,6 +118,8 @@
   --score-title-font-size: 20px;
   --score-title-line-height-big: 36px;
   --score-title-line-height: 26px;
+  --scorescale-height: 6px;
+  --scorescale-width: 18px;
   --section-padding: 40px;
   --topbar-bg: var(--color-black-100);
   --topbar-height: 36px;
@@ -130,6 +132,8 @@
   --color-fail: var(--color-red);
   --color-pass-secondary: var(--color-green-700);
   --color-pass: var(--color-green);
+  --color-sticky-header-bg: var(--color-white);
+  --color-highlighter-bg: var(--color-black);
 
   --plugin-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="%23757575"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>');
   --logo-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192"><g fill="none" fill-rule="evenodd"><path d="M0 0h192v192H0z"/><path d="M67.705 179.352l2.603-20.82 49.335-16.39 4.652 37.21A87.893 87.893 0 0 1 96 184a87.893 87.893 0 0 1-28.295-4.648zM52.44 172.48C25.894 157.328 8 128.754 8 96 8 47.399 47.399 8 96 8s88 39.399 88 88c0 32.754-17.894 61.328-44.44 76.48L130 96h6V80h-8V48L96 28 64 48v32h-8v16h6l-9.56 76.48zM113.875 96l2.882 23.05-43.318 14.433L78.125 96h35.75zM80 80V56.868l16-10 16 10V80H80z" fill="%23000000"/></g></svg>');
@@ -394,7 +398,7 @@
 /* Expandable Details (Audit Groups, Audits) */
 .lh-audit__header {
   display: flex;
-  padding: var(--lh-audit-vpadding) var(--text-indent);
+  padding: var(--lh-audit-vpadding) 0;
 }
 
 .lh-audit--load-opportunity .lh-audit__header {
@@ -646,6 +650,15 @@
   border-bottom: 1px solid var(--report-secondary-border-color);
 }
 
+/* Apply border-top to just the first audit. */
+.lh-audit {
+  border-top: 1px solid var(--report-secondary-border-color);
+}
+.lh-audit ~ .lh-audit {
+  border-top: none;
+}
+
+
 .lh-audit--error .lh-audit__display-text {
   color: var(--color-fail);
 }
@@ -653,7 +666,7 @@
 /* Audit Group */
 
 .lh-audit-group {
-  margin: var(--lh-audit-group-vpadding) 0;
+  margin-bottom: var(--audits-margin-bottom);
 }
 
 .lh-audit-group__header::before {
@@ -688,11 +701,6 @@
   background-image: var(--pwa-optimized-color-url);
 }
 
-/* Removing too much whitespace */
-.lh-audit-group--metrics {
-  margin-top: calc(var(--gauge-circle-size)/2 * -1);
-  border-bottom: none;
-}
 .lh-audit-group--metrics .lh-audit-group__summary {
   margin-top: 0;
   margin-bottom: 0;
@@ -717,6 +725,8 @@
 .lh-audit-group__header {
   font-size: var(--body-font-size);
   margin: 0 0 var(--lh-audit-group-vpadding);
+  /* When the header takes 100% width, the chevron becomes small. */
+  max-width: calc(100% - var(--chevron-size));
 }
 
 .lh-audit-group__header span.lh-audit-group__title {
@@ -834,8 +844,14 @@
   border: 0;
 }
 
+.lh-gauge__wrapper {
+  padding: var(--score-container-padding);
+}
+
 .lh-scores-header .lh-gauge__wrapper,
-.lh-scores-header .lh-gauge--pwa__wrapper {
+.lh-scores-header .lh-gauge--pwa__wrapper,
+.lh-sticky-header .lh-gauge__wrapper,
+.lh-sticky-header .lh-gauge--pwa__wrapper {
   width: var(--score-container-width);
 }
 
@@ -853,6 +869,8 @@
 }
 
 .lh-scorescale-range {
+  display: flex;
+  align-items: center;
   margin: 0 12px;
   font-family: var(--monospace-font-family);
   white-space: nowrap;
@@ -860,11 +878,11 @@
 
 .lh-scorescale-range::before {
   content: '';
-  width: var(--body-font-size);
-  height: calc(var(--body-font-size) * .50);
+  width: var(--scorescale-width);
+  height: var(--scorescale-height);
   border-radius: 10px;
-  display: inline-block;
-  margin: 0 5px;
+  display: block;
+  margin-right: 10px;
 }
 
 .lh-scorescale-range--pass::before {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 1b16ab052..77abd4ac3 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -26,9 +26,9 @@ limitations under the License.
 <!-- Lighthouse score scale -->
 <template id="tmpl-lh-scorescale">
   <div class="lh-scorescale">
-      <span class="lh-scorescale-range lh-scorescale-range--fail">0-49</span>
-      <span class="lh-scorescale-range lh-scorescale-range--average">50-89</span>
-      <span class="lh-scorescale-range lh-scorescale-range--pass">90-100</span>
+      <span class="lh-scorescale-range lh-scorescale-range--fail">0&ndash;49</span>
+      <span class="lh-scorescale-range lh-scorescale-range--average">50&ndash;89</span>
+      <span class="lh-scorescale-range lh-scorescale-range--pass">90&ndash;100</span>
   </div>
 </template>
 
@@ -144,10 +144,49 @@ limitations under the License.
       position: relative;
       width: 100%;
     }
+
+    .lh-sticky-header {
+      --gauge-circle-size: 36px;
+      --plugin-badge-size: 18px;
+      --plugin-icon-size: 75%;
+      --score-container-width: 60px;
+      --score-number-font-size: 13px;
+      position: fixed;
+      left: 0;
+      right: 0;
+      top: var(--topbar-height);
+      font-weight: 700;
+      display: flex;
+      justify-content: center;
+      background-color: var(--color-sticky-header-bg);
+      border-bottom: 1px solid var(--color-black-200);
+      padding-top: var(--score-container-padding);
+      padding-bottom: 4px;
+      z-index: 1000;
+      pointer-events: none;
+      opacity: 0;
+    }
+
+    .lh-sticky-header--visible {
+      pointer-events: auto;
+      opacity: 1;
+    }
+
+    .lh-sticky-header .lh-gauge__label {
+      display: none;
+    }
+
+    .lh-highlighter {
+      width: var(--score-container-width);
+      height: 1px;
+      background: var(--color-highlighter-bg);
+      position: absolute;
+      bottom: -1px;
+      left: 0;
+    }
   </style>
   <div class="lh-scores-wrapper">
-    <div class="lh-scores-container">
-    </div>
+    <div class="lh-scores-container"></div>
   </div>
 </template>
 
@@ -500,7 +539,12 @@ limitations under the License.
 
     .lh-gauge__svg-wrapper {
       position: relative;
+      height: var(--gauge-circle-size);
+    }
+    .lh-category .lh-gauge__svg-wrapper {
+      --gauge-circle-size: var(--gauge-circle-size-big);
     }
+
     /* The plugin badge overlay */
     .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before {
       width: var(--plugin-badge-size);
@@ -513,7 +557,7 @@ limitations under the License.
       content: "";
       position: absolute;
       right: -6px;
-      bottom: -2px;
+      bottom: 5px;
       display: block;
       z-index: 100;
       box-shadow: 0 0 4px rgba(0,0,0,.2);
@@ -537,15 +581,16 @@ limitations under the License.
       font-size: var(--score-number-font-size);
       line-height: var(--score-number-font-size);
       text-align: center;
-      top: calc(var(--gauge-circle-size) / 3);
+      top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 3);
     }
 
     .lh-category .lh-gauge__percentage {
       --gauge-circle-size: var(--gauge-circle-size-big);
-      --score-number-fontsize: var(--score-number-fontsize-big);
+      --score-number-font-size: var(--score-number-font-size-big);
     }
 
     .lh-gauge__wrapper {
+      position: relative;
       display: flex;
       align-items: center;
       flex-direction: column;
@@ -562,7 +607,7 @@ limitations under the License.
     .lh-gauge__label {
       font-size: var(--score-title-font-size);
       line-height: var(--score-title-line-height);
-      margin-bottom: calc(0.5 * var(--score-title-line-height));
+      margin-top: 10px;
       text-align: center;
       color: black;
     }
@@ -571,7 +616,7 @@ limitations under the License.
     .lh-category .lh-gauge__label {
       --score-title-font-size: var(--score-title-font-size-big);
       --score-title-line-height: var(--score-title-line-height-big);
-      margin: calc(0.5 * var(--score-title-line-height));
+      margin-top: 14px;
     }
 
   </style>
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/release/publish-clean-pristine.sh b/lighthouse-core/scripts/release/publish-clean-pristine.sh
new file mode 100755
index 000000000..f4bf04a26
--- /dev/null
+++ b/lighthouse-core/scripts/release/publish-clean-pristine.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT="$DIRNAME/../../.."
+cd $LH_ROOT
+
+set -euxo pipefail
+
+# Setup a pristine git environment
+cd ../lighthouse-pristine
+
+if [[ -z "$(git status --porcelain)" ]]; then
+  echo "Pristine repo already clean!"
+  exit 0
+fi
+
+git clean -fx
diff --git a/lighthouse-core/scripts/release/publish-prepare-commit.sh b/lighthouse-core/scripts/release/publish-prepare-commit.sh
new file mode 100755
index 000000000..1f7bd65c6
--- /dev/null
+++ b/lighthouse-core/scripts/release/publish-prepare-commit.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+TXT_BOLD=$(tput bold)
+TXT_DIM=$(tput setaf 245)
+TXT_RESET=$(tput sgr0)
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT="$DIRNAME/../../.."
+cd "$LH_ROOT"
+
+set -euxo pipefail
+
+if [[ "$#" -ne 1 ]]; then
+  echo "You must specify the version to prepare a commit for!"
+  exit 1
+fi
+
+OLD_VERSION=$(node -e "console.log(require('./package.json').version)")
+NEW_VERSION=$1
+BRANCH_NAME="bump_$NEW_VERSION"
+SEMVER_PATTERN="[0-9]*\.[0-9]*\.[0-9]*"
+
+if [[ $(echo "$NEW_VERSION" | sed 's/[0-9]*\.[0-9]*\.[0-9]*/SECRET_REPLACE/g') != "SECRET_REPLACE" ]]; then
+ echo "Incorrect version format. Must be x.x.x"
+ exit 1
+fi
+
+if [[ -n "$(git status --porcelain)" ]]; then
+  echo "Repo has changes to the files! Commit or stash the changes to continue."
+  exit 1
+fi
+
+# Checkout a new branch for the version commit
+git fetch origin master
+git checkout origin/master
+git log -n 1
+git branch -D "$BRANCH_NAME" || true
+git checkout -b "$BRANCH_NAME"
+
+# Install the dependencies.
+yarn install
+
+# Bump the version in package.json and clients/extension/manifest.json
+NEEDLE="^  \"version\": \"$SEMVER_PATTERN\""
+REPLACEMENT="  \"version\": \"$NEW_VERSION\""
+
+sed -i '' "s/$NEEDLE/$REPLACEMENT/g" package.json clients/extension/manifest.json
+
+# Update the fixtures with the new version
+yarn update:sample-json
+
+# Create the changelog entry
+yarn changelog
+
+# Add new contributors to changelog
+git --no-pager shortlog -s -e -n "v2.3.0..v${OLD_VERSION}" | cut -f 2 | sort > auto_contribs_prior_to_last
+git --no-pager shortlog -s -e -n "v${OLD_VERSION}..HEAD" | cut -f 2 | sort > auto_contribs_since_last
+NEW_CONTRIBUTORS=$(comm -13 auto_contribs_prior_to_last auto_contribs_since_last)
+rm auto_contribs_prior_to_last auto_contribs_since_last
+
+if [[ $(echo "$NEW_CONTRIBUTORS" | wc -l) -gt 1 ]]; then
+  printf "Thanks to our new contributors 👽🐷🐰🐯🐻! \n$NEW_CONTRIBUTORS\n" | cat - changelog.md > tmp-changelog
+  mv tmp-changelog changelog.md
+fi
+
+git add changelog.md lighthouse-core/test/results/ proto/
+git commit -m "$NEW_VERSION"
+
+echo "Version bump commit ready on the ${TXT_BOLD}$BRANCH_NAME${TXT_RESET} branch!"
+
+echo "${TXT_DIM}Press any key to see the git diff, CTRL+C to exit...${TXT_RESET}"
+read -n 1 -r unused_variable
+git --no-pager diff HEAD^
+echo "${TXT_DIM}Press any key to push to GitHub, CTRL+C to exit...${TXT_RESET}"
+read -n 1 -r unused_variable
+git push -u origin "$BRANCH_NAME"
diff --git a/lighthouse-core/scripts/release/publish-prepare-package.sh b/lighthouse-core/scripts/release/publish-prepare-package.sh
new file mode 100755
index 000000000..c5398c5d7
--- /dev/null
+++ b/lighthouse-core/scripts/release/publish-prepare-package.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_PRISTINE_ROOT="$DIRNAME/../../../../lighthouse-pristine"
+
+set -euxo pipefail
+
+bash "$DIRNAME/release-prepare-pristine.sh"
+
+cd "$LH_PRISTINE_ROOT"
+
+VERSION=$(node -e "console.log(require('./package.json').version)")
+
+if ! git rev-parse "v$VERSION" ; then
+  if ! git --no-pager log -n 1 --oneline | grep "v$VERSION" ; then
+    echo "Cannot tag a commit other than the version bump!";
+    exit 1;
+  fi
+
+  git tag -a "v$VERSION" -m "v$VERSION"
+fi
+
+git checkout -f "v$VERSION"
+
+# Install the dependencies.
+yarn install
+
+# Build everything
+yarn build-all
+
+# Package the extension
+node build/build-extension.js package
+
+# Verify the npm package won't include unncessary files
+npm pack --dry-run
+npx pkgfiles
+
+echo "Make sure the files above look good!"
+
diff --git a/lighthouse-core/scripts/release/publish-prepare-pristine.sh b/lighthouse-core/scripts/release/publish-prepare-pristine.sh
new file mode 100755
index 000000000..960136ed0
--- /dev/null
+++ b/lighthouse-core/scripts/release/publish-prepare-pristine.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT="$DIRNAME/../../.."
+cd $LH_ROOT
+
+set -euxo pipefail
+
+# Setup a pristine git environment
+cd ../
+
+if [[ ! -e lighthouse-pristine/ ]]; then
+  git clone git@github.com:GoogleChrome/lighthouse.git lighthouse-pristine
+fi
+
+cd lighthouse-pristine/
+
+if [[ -n "$(git status --porcelain)" ]]; then
+  echo "Pristine repo has changes to the files! Commit or stash the changes to continue."
+  exit 1
+fi
+
+git fetch origin
+git fetch --tags
+git checkout -f master
+git reset --hard origin/master
+git clean -fdx # Forcibly clean all untracked files and directories, including `.gitignore`d ones.
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 215f93705..d7fecdafd 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -461,7 +461,6 @@
                 "type": "debugdata"
             }, 
             "id": "diagnostics", 
-            "numericValue": 1.0, 
             "score": null, 
             "scoreDisplayMode": "informative", 
             "title": "Diagnostics"
@@ -3653,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/audit.d.ts b/types/audit.d.ts
index 9be3f533d..05ecf3d85 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -81,7 +81,7 @@ declare global {
       /** Overrides scoreDisplayMode with notApplicable if set to true */
       notApplicable?: boolean;
       /** A numeric value that has a meaning specific to the audit, e.g. the number of nodes in the DOM or the timestamp of a specific load event. More information can be found in the audit details, if present. */
-      rawValue?: number;
+      numericValue?: number;
       /** Extra information about the page provided by some types of audits, in one of several possible forms that can be rendered in the HTML report. */
       details?: Audit.Details;
     }
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/scripts/release/publish-test.sh b/lighthouse-core/scripts/release/publish-test.sh
new file mode 100755
index 000000000..2a5b52765
--- /dev/null
+++ b/lighthouse-core/scripts/release/publish-test.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+
+TXT_BOLD=$(tput bold)
+TXT_DIM=$(tput setaf 245)
+TXT_RESET=$(tput sgr0)
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_PRISTINE_ROOT="$DIRNAME/../../../../lighthouse-pristine"
+
+set -euxo pipefail
+
+bash "$DIRNAME/release-prepare-pristine.sh"
+
+cd "$LH_PRISTINE_ROOT"
+
+# Install deps
+yarn --check-files
+
+# Test err'thing
+echo "${TXT_BOLD}Building all the clients..."
+yarn build-all
+
+echo "Running the standard test suite..."
+yarn test
+
+echo "Running the smoke tests...."
+yarn smoke
+
+echo "Testing the CLI..."
+yarn start "https://example.com" --view
+
+echo "Testing a fresh local install..."
+VERSION=$(node -e "console.log(require('./package.json').version)")
+npm pack
+
+rm -rf /tmp/lighthouse-local-test || true
+mkdir -p /tmp/lighthouse-local-test
+cd /tmp/lighthouse-local-test
+
+npm init -y
+npm install "$LH_PRISTINE_ROOT/lighthouse-$VERSION.tgz"
+npm explore lighthouse -- npm run smoke
+npm explore lighthouse -- npm run chrome # try the manual launcher
+npm explore lighthouse -- npm run fast -- http://example.com
+
+cd "$LH_PRISTINE_ROOT"
+rm -rf /tmp/lighthouse-local-test
+rm "lighthouse-$VERSION.tgz"
+
+echo "${TXT_BOLD}Now manually...${TXT_RESET}"
+echo "✅   Test the extension. Open chrome://extensions"
+echo "${TXT_DIM}Press any key to continue...${TXT_RESET}"
+read -n 1 -r unused_variable
+
+
+echo "✅   Test the viewer. Open http://localhost:8000"
+echo "    - Works with v4 report? https://gist.github.com/patrickhulce/7251f9eba409f385e4c0424515fe8009"
+echo "${TXT_DIM}Press any key to complete the test script...${TXT_RESET}"
+read -n 1 -r unused_variable
diff --git a/lighthouse-core/test/audits/bootup-time-test.js b/lighthouse-core/test/audits/bootup-time-test.js
index a91cb3be5..5365626bc 100644
--- a/lighthouse-core/test/audits/bootup-time-test.js
+++ b/lighthouse-core/test/audits/bootup-time-test.js
@@ -37,7 +37,7 @@ describe('Performance: bootup-time audit', () => {
       assert.deepEqual(roundedValueOf(output, 'https://www.google-analytics.com/analytics.js'), {scripting: 40.6, scriptParseCompile: 9.6, total: 53.4});
       assert.deepEqual(roundedValueOf(output, 'Other'), {scripting: 11.7, scriptParseCompile: 0, total: 1123.8}); // eslint-disable-line max-len
 
-      assert.equal(Math.round(output.rawValue), 225);
+      assert.equal(Math.round(output.numericValue), 225);
       assert.equal(output.details.items.length, 5);
       assert.equal(output.score, 1);
     });
@@ -58,7 +58,7 @@ describe('Performance: bootup-time audit', () => {
 
     assert.equal(output.details.items.length, 8);
     assert.equal(output.score, 0.98);
-    assert.equal(Math.round(output.rawValue), 720);
+    assert.equal(Math.round(output.numericValue), 720);
   });
 
   it('should get no data when no events are present', () => {
@@ -72,7 +72,7 @@ describe('Performance: bootup-time audit', () => {
       .then(output => {
         assert.equal(output.details.items.length, 0);
         assert.equal(output.score, 1);
-        assert.equal(Math.round(output.rawValue), 0);
+        assert.equal(Math.round(output.numericValue), 0);
       });
   });
 });
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index d000649f6..1dd1369af 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -88,7 +88,7 @@ describe('Byte efficiency base audit', () => {
     assert.deepEqual(result.details.items, []);
   });
 
-  it('should set the rawValue', () => {
+  it('should set the numericValue', () => {
     const result = ByteEfficiencyAudit.createAuditProduct(
       {
         headings: baseHeadings,
@@ -101,7 +101,7 @@ describe('Byte efficiency base audit', () => {
     );
 
     // 900ms savings comes from the graph calculation
-    assert.equal(result.rawValue, 900);
+    assert.equal(result.numericValue, 900);
   });
 
   it('should score the wastedMs', () => {
@@ -200,7 +200,7 @@ describe('Byte efficiency base audit', () => {
       simulator
     );
 
-    assert.equal(result.rawValue, 300);
+    assert.equal(result.numericValue, 300);
   });
 
   it('should create load simulator with the specified settings', async () => {
@@ -224,14 +224,14 @@ describe('Byte efficiency base audit', () => {
     let settings = {throttlingMethod: 'simulate', throttling: modestThrottling};
     let result = await MockAudit.audit(artifacts, {settings, computedCache});
     // expect modest savings
-    expect(result.rawValue).toBeLessThan(5000);
-    expect(result.rawValue).toMatchSnapshot();
+    expect(result.numericValue).toBeLessThan(5000);
+    expect(result.numericValue).toMatchSnapshot();
 
     settings = {throttlingMethod: 'simulate', throttling: ultraSlowThrottling};
     result = await MockAudit.audit(artifacts, {settings, computedCache});
     // expect lots of savings
-    expect(result.rawValue).not.toBeLessThan(5000);
-    expect(result.rawValue).toMatchSnapshot();
+    expect(result.numericValue).not.toBeLessThan(5000);
+    expect(result.numericValue).toMatchSnapshot();
   });
 
   it('should allow overriding of computeWasteWithTTIGraph', async () => {
@@ -262,7 +262,7 @@ describe('Byte efficiency base audit', () => {
     const result = await MockAudit.audit(artifacts, {settings, computedCache});
     const resultTti = await MockJustTTIAudit.audit(artifacts, {settings, computedCache});
     // expect less savings with just TTI
-    expect(resultTti.rawValue).toBeLessThan(result.rawValue);
-    expect({default: result.rawValue, justTTI: resultTti.rawValue}).toMatchSnapshot();
+    expect(resultTti.numericValue).toBeLessThan(result.numericValue);
+    expect({default: result.numericValue, justTTI: resultTti.numericValue}).toMatchSnapshot();
   });
 });
diff --git a/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js b/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
index d0b256e7b..d1fd48b27 100644
--- a/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
@@ -36,7 +36,7 @@ describe('Render blocking resources audit', () => {
     const computedCache = new Map();
     const result = await RenderBlockingResourcesAudit.audit(artifacts, {settings, computedCache});
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 0);
+    assert.equal(result.numericValue, 0);
   });
 
   describe('#estimateSavingsWithGraphs', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
index 90afd4e46..2fe5e7e91 100644
--- a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
@@ -47,7 +47,7 @@ describe('Total byte weight audit', () => {
     ]);
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 150 * 1024);
+      assert.strictEqual(result.numericValue, 150 * 1024);
       assert.strictEqual(result.score, 1);
       const results = result.details.items;
       assert.strictEqual(results.length, 3);
@@ -73,7 +73,7 @@ describe('Total byte weight audit', () => {
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
       assert.ok(0.40 < result.score && result.score < 0.6, 'score is around 0.5');
-      assert.strictEqual(result.rawValue, 4180 * 1024);
+      assert.strictEqual(result.numericValue, 4180 * 1024);
       const results = result.details.items;
       assert.strictEqual(results.length, 10, 'results are clipped at top 10');
       assert.strictEqual(result.extendedInfo.value.totalCompletedRequests, 11);
@@ -88,7 +88,7 @@ describe('Total byte weight audit', () => {
     ]);
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 15000 * 1024);
+      assert.strictEqual(result.numericValue, 15000 * 1024);
       assert.strictEqual(result.score, 0);
     });
   });
diff --git a/lighthouse-core/test/audits/dobetterweb/dom-size-test.js b/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
index 152a6eb41..509568c44 100644
--- a/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
@@ -24,7 +24,7 @@ describe('DOMSize audit', () => {
   it('calculates score hitting mid distribution', () => {
     const auditResult = DOMSize.audit(artifact, {options});
     assert.equal(auditResult.score, 0.43);
-    assert.equal(auditResult.rawValue, numElements);
+    assert.equal(auditResult.numericValue, numElements);
     expect(auditResult.displayValue).toBeDisplayString('1,500 elements');
     assert.equal(auditResult.details.items[0].value, numElements.toLocaleString());
     assert.equal(auditResult.details.items[1].value, '1');
diff --git a/lighthouse-core/test/audits/errors-in-console-test.js b/lighthouse-core/test/audits/errors-in-console-test.js
index 6382a9d46..482d52a0d 100644
--- a/lighthouse-core/test/audits/errors-in-console-test.js
+++ b/lighthouse-core/test/audits/errors-in-console-test.js
@@ -16,7 +16,7 @@ describe('Console error logs audit', () => {
       ConsoleMessages: [],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 0);
+    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.ok(!auditResult.displayValue, 0);
     assert.equal(auditResult.details.items.length, 0);
@@ -35,7 +35,7 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 0);
+    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.equal(auditResult.details.items.length, 0);
   });
@@ -81,7 +81,7 @@ describe('Console error logs audit', () => {
       }],
     });
 
-    assert.equal(auditResult.rawValue, 3);
+    assert.equal(auditResult.numericValue, 3);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[0].url, 'http://www.example.com/favicon.ico');
@@ -107,7 +107,7 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 1);
+    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     // url is undefined
@@ -138,7 +138,7 @@ describe('Console error logs audit', () => {
         },
       }],
     });
-    assert.equal(auditResult.rawValue, 1);
+    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     assert.strictEqual(auditResult.details.items[0].url, 'http://example.com/fancybox.js');
diff --git a/lighthouse-core/test/audits/font-display-test.js b/lighthouse-core/test/audits/font-display-test.js
index 7d3a22881..36b674050 100644
--- a/lighthouse-core/test/audits/font-display-test.js
+++ b/lighthouse-core/test/audits/font-display-test.js
@@ -261,4 +261,25 @@ describe('Performance: Font Display audit', () => {
     expect(result.details.items).toEqual([]);
     assert.strictEqual(result.score, 1);
   });
+
+  it('handles custom source URLs from sourcemaps', async () => {
+    // Make sure we don't use sourceURL when it's not a valid URL, see https://github.com/GoogleChrome/lighthouse/issues/8534
+    stylesheet.header.sourceURL = 'custom-url-from-source-map';
+    stylesheet.content = `
+      @font-face {
+        src: url(font-0.woff);
+        font-display: swap
+      }
+    `;
+
+    networkRecords = [{
+      url: `https://example.com/foo/bar/font-0.woff`,
+      endTime: 2, startTime: 1,
+      resourceType: 'Font',
+    }];
+
+    const result = await FontDisplayAudit.audit(getArtifacts(), context);
+    expect(result.details.items).toEqual([]);
+    assert.strictEqual(result.score, 1);
+  });
 });
diff --git a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
index 90d2eceab..4b2ac5211 100644
--- a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
+++ b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
@@ -26,7 +26,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     return FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, true, 'fixture trace is not passing audit');
-      assert.equal(result.rawValue, 1582.189);
+      assert.equal(result.numericValue, 1582.189);
     });
   });
 
@@ -50,7 +50,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     return FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, false, 'not failing a long TTI value');
-      assert.equal(result.rawValue, 15000);
+      assert.equal(result.numericValue, 15000);
       expect(result.displayValue).toBeDisplayString('Interactive at 15.0\xa0s');
       assert.ok(result.explanation);
     });
@@ -64,7 +64,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    assert.equal(Math.round(result.rawValue), 1582);
+    assert.equal(Math.round(result.numericValue), 1582);
   });
 
   it('overrides with simulated result when throttling is modified', async () => {
@@ -75,8 +75,8 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'provided', throttling: {rttMs: 40, throughput: 100000}};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    expect(result.rawValue).toBeGreaterThan(2000); // If not overridden this would be 1582
-    expect(Math.round(result.rawValue)).toMatchSnapshot();
+    expect(result.numericValue).toBeGreaterThan(2000); // If not overridden this would be 1582
+    expect(Math.round(result.numericValue)).toMatchSnapshot();
   });
 
   it('overrides when throttling is modified but method is not "provided"', async () => {
@@ -87,7 +87,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'devtools', throttling: {rttMs: 40, throughput: 100000}};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    expect(result.rawValue).toBeGreaterThan(2000); // If not overridden this would be 1582
+    expect(result.numericValue).toBeGreaterThan(2000); // If not overridden this would be 1582
   });
 
   it('overrides when throttling is "provided" and fails the simulated TTI value', async () => {
@@ -110,6 +110,6 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
     expect(result.displayValue)
       .toBeDisplayString('Interactive on simulated mobile network at 24.9\xa0s');
-    expect(result.rawValue).toBeGreaterThan(10000);
+    expect(result.numericValue).toBeGreaterThan(10000);
   });
 });
diff --git a/lighthouse-core/test/audits/mainthread-work-breakdown-test.js b/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
index 4710b4ac1..e3f5e473d 100644
--- a/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
+++ b/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
@@ -56,7 +56,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), acceptableTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 1360);
+    assert.equal(Math.round(output.numericValue), 1360);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 0.98);
   });
@@ -75,7 +75,7 @@ describe('Performance: page execution timings audit', () => {
       assert.ok(Math.abs(actual - expected) <= 2, `expected ${expected} got ${actual}`);
     }
 
-    assert.equal(Math.round(output.rawValue), 4081);
+    assert.equal(Math.round(output.numericValue), 4081);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 0.49);
   });
@@ -85,7 +85,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), siteWithRedirectTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 784);
+    assert.equal(Math.round(output.numericValue), 784);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 1);
   });
@@ -95,7 +95,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), loadTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 933);
+    assert.equal(Math.round(output.numericValue), 933);
     assert.equal(output.details.items.length, 6);
     assert.equal(output.score, 1);
   });
@@ -107,7 +107,7 @@ describe('Performance: page execution timings audit', () => {
     return PageExecutionTimings.audit(artifacts, context).then(output => {
       assert.equal(output.details.items.length, 0);
       assert.equal(output.score, 1);
-      assert.equal(Math.round(output.rawValue), 0);
+      assert.equal(Math.round(output.numericValue), 0);
     });
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap b/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
index 4ca3dd6f7..ab837653f 100644
--- a/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
+++ b/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
@@ -2,7 +2,7 @@
 
 exports[`Performance: first-meaningful-paint audit computes FMP correctly for simulated 1`] = `
 Object {
-  "rawValue": 1510.6100000208241,
+  "numericValue": 1510.6100000208241,
   "score": 0.99,
 }
 `;
diff --git a/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js b/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
index f52772738..d51d9e043 100644
--- a/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
+++ b/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
@@ -25,7 +25,7 @@ describe('Performance: estimated-input-latency audit', () => {
     const settings = {throttlingMethod: 'provided'};
     const context = {options, settings, computedCache: new Map()};
     return Audit.audit(artifacts, context).then(output => {
-      assert.equal(Math.round(output.rawValue * 10) / 10, 17.1);
+      assert.equal(Math.round(output.numericValue * 10) / 10, 17.1);
       assert.equal(output.score, 1);
       expect(output.displayValue).toBeDisplayString('20\xa0ms');
     });
diff --git a/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js b/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
index b231a304d..120d29287 100644
--- a/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
+++ b/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
@@ -26,7 +26,7 @@ describe('Performance: first-contentful-paint-3g audit', () => {
 
     const result = await FCP3G.audit(artifacts, {settings: {}, options, computedCache: new Map()});
     // Use InlineSnapshot here so changes to Lantern coefficients can be easily updated en masse
-    expect({score: result.score, value: Math.round(result.rawValue)}).toMatchInlineSnapshot(`
+    expect({score: result.score, value: Math.round(result.numericValue)}).toMatchInlineSnapshot(`
 Object {
   "score": 0.99,
   "value": 2057,
diff --git a/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js b/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
index ee3ee168c..7be3496bb 100644
--- a/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
+++ b/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
@@ -27,6 +27,6 @@ describe('Performance: first-contentful-paint audit', () => {
     const settings = {throttlingMethod: 'provided'};
     const result = await Audit.audit(artifacts, {settings, options, computedCache: new Map()});
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 498.87);
+    assert.equal(result.numericValue, 498.87);
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js b/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
index b3862b65c..61a416e72 100644
--- a/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
+++ b/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
@@ -23,7 +23,7 @@ describe('Performance: first-meaningful-paint audit', () => {
     const fmpResult = await FMPAudit.audit(artifacts, context);
 
     assert.equal(fmpResult.score, 1);
-    assert.equal(fmpResult.rawValue, 783.328);
+    assert.equal(fmpResult.numericValue, 783.328);
     expect(fmpResult.displayValue).toBeDisplayString('0.8\xa0s');
   });
 
@@ -37,7 +37,7 @@ describe('Performance: first-meaningful-paint audit', () => {
 
     expect({
       score: fmpResult.score,
-      rawValue: fmpResult.rawValue,
+      numericValue: fmpResult.numericValue,
     }).toMatchSnapshot();
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/interactive-test.js b/lighthouse-core/test/audits/metrics/interactive-test.js
index 20fcef213..40efb636e 100644
--- a/lighthouse-core/test/audits/metrics/interactive-test.js
+++ b/lighthouse-core/test/audits/metrics/interactive-test.js
@@ -32,7 +32,7 @@ describe('Performance: interactive audit', () => {
     const context = {options, settings: {throttlingMethod: 'provided'}, computedCache: new Map()};
     return Interactive.audit(artifacts, context).then(output => {
       assert.equal(output.score, 1);
-      assert.equal(Math.round(output.rawValue), 1582);
+      assert.equal(Math.round(output.numericValue), 1582);
       expect(output.displayValue).toBeDisplayString('1.6\xa0s');
     });
   });
@@ -50,7 +50,7 @@ describe('Performance: interactive audit', () => {
     const context = {options, settings: {throttlingMethod: 'provided'}, computedCache: new Map()};
     return Interactive.audit(artifacts, context).then(output => {
       assert.equal(output.score, 0.97);
-      assert.equal(Math.round(output.rawValue), 2712);
+      assert.equal(Math.round(output.numericValue), 2712);
       expect(output.displayValue).toBeDisplayString('2.7\xa0s');
     });
   });
diff --git a/lighthouse-core/test/audits/metrics/speed-index-test.js b/lighthouse-core/test/audits/metrics/speed-index-test.js
index 199485036..48d6e7a91 100644
--- a/lighthouse-core/test/audits/metrics/speed-index-test.js
+++ b/lighthouse-core/test/audits/metrics/speed-index-test.js
@@ -24,7 +24,7 @@ describe('Performance: speed-index audit', () => {
     const settings = {throttlingMethod: 'provided'};
     return Audit.audit(artifacts, {options, settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, 1);
-      assert.equal(result.rawValue, 605);
+      assert.equal(result.numericValue, 605);
     });
   }, 10000);
 });
diff --git a/lighthouse-core/test/audits/network-requests-test.js b/lighthouse-core/test/audits/network-requests-test.js
index 6703d78e7..da967a2f5 100644
--- a/lighthouse-core/test/audits/network-requests-test.js
+++ b/lighthouse-core/test/audits/network-requests-test.js
@@ -22,7 +22,7 @@ describe('Network requests audit', () => {
 
     return NetworkRequests.audit(artifacts, {computedCache: new Map()}).then(output => {
       assert.equal(output.score, 1);
-      assert.equal(output.rawValue, 66);
+      assert.equal(output.numericValue, 66);
       assert.equal(output.details.items.length, 66);
       assert.equal(output.details.items[0].url, 'https://pwa.rocks/');
       assert.equal(output.details.items[0].startTime, 0);
diff --git a/lighthouse-core/test/audits/redirects-test.js b/lighthouse-core/test/audits/redirects-test.js
index 1bc6ded0e..94dfec03e 100644
--- a/lighthouse-core/test/audits/redirects-test.js
+++ b/lighthouse-core/test/audits/redirects-test.js
@@ -97,7 +97,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(Math.round(output.score * 100) / 100, 0.37);
       assert.equal(output.details.items.length, 4);
-      assert.equal(output.rawValue, 1890);
+      assert.equal(output.numericValue, 1890);
     });
   });
 
@@ -107,7 +107,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(Math.round(output.score * 100) / 100, 0.46);
       assert.equal(output.details.items.length, 3);
-      assert.equal(Math.round(output.rawValue), 1110);
+      assert.equal(Math.round(output.numericValue), 1110);
     });
   });
 
@@ -119,7 +119,7 @@ describe('Performance: Redirects audit', () => {
       assert.equal(output.score, 1);
       // We will still generate a table and show wasted time
       assert.equal(output.details.items.length, 2);
-      assert.equal(Math.round(output.rawValue), 780);
+      assert.equal(Math.round(output.numericValue), 780);
     });
   });
 
@@ -129,7 +129,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(output.score, 1);
       assert.equal(output.details.items.length, 0);
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
     });
   });
 });
diff --git a/lighthouse-core/test/audits/time-to-first-byte-test.js b/lighthouse-core/test/audits/time-to-first-byte-test.js
index c0c8abd73..97b50ac17 100644
--- a/lighthouse-core/test/audits/time-to-first-byte-test.js
+++ b/lighthouse-core/test/audits/time-to-first-byte-test.js
@@ -25,7 +25,7 @@ describe('Performance: time-to-first-byte audit', () => {
     };
 
     return TimeToFirstByte.audit(artifacts, {computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 630);
+      assert.strictEqual(result.numericValue, 630);
       assert.strictEqual(result.score, 0);
     });
   });
@@ -44,7 +44,7 @@ describe('Performance: time-to-first-byte audit', () => {
     };
 
     return TimeToFirstByte.audit(artifacts, {computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 200);
+      assert.strictEqual(result.numericValue, 200);
       assert.strictEqual(result.score, 1);
     });
   });
diff --git a/lighthouse-core/test/audits/uses-rel-preconnect-test.js b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
index 957e52508..07322ce44 100644
--- a/lighthouse-core/test/audits/uses-rel-preconnect-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
@@ -36,9 +36,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -61,9 +61,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -83,9 +83,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -111,9 +111,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -134,9 +134,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -201,8 +201,8 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {rawValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
-    assert.equal(rawValue, 300);
+    const {numericValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
+    assert.equal(numericValue, 300);
     assert.equal(extendedInfo.value.length, 1);
     assert.deepStrictEqual(extendedInfo.value, [
       {url: 'https://cdn.example.com', wastedMs: 300},
@@ -242,8 +242,8 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {rawValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
-    assert.equal(rawValue, 300);
+    const {numericValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
+    assert.equal(numericValue, 300);
     assert.equal(extendedInfo.value.length, 2);
     assert.deepStrictEqual(extendedInfo.value, [
       {url: 'https://othercdn.example.com', wastedMs: 300},
diff --git a/lighthouse-core/test/audits/uses-rel-preload-test.js b/lighthouse-core/test/audits/uses-rel-preload-test.js
index 6765a7370..c97fb5a78 100644
--- a/lighthouse-core/test/audits/uses-rel-preload-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preload-test.js
@@ -205,7 +205,7 @@ describe('Performance: uses-rel-preload audit', () => {
     const artifacts = mockArtifacts(networkRecords, defaultMainResourceUrl);
     const context = {settings: {}, computedCache: new Map()};
     return UsesRelPreload.audit(artifacts, context).then(output => {
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
       assert.equal(output.details.items.length, 0);
     });
   });
@@ -217,7 +217,7 @@ describe('Performance: uses-rel-preload audit', () => {
     const artifacts = mockArtifacts(networkRecords, defaultMainResourceUrl);
     const context = {settings: {}, computedCache: new Map()};
     return UsesRelPreload.audit(artifacts, context).then(output => {
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
       assert.equal(output.details.items.length, 0);
     });
   });
@@ -237,6 +237,6 @@ describe('Performance: uses-rel-preload audit', () => {
     const context = {settings, computedCache: new Map()};
     const result = await UsesRelPreload.audit(artifacts, context);
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 0);
+    assert.equal(result.numericValue, 0);
   });
 });
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/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index e3afa14fa..cdc961b2a 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -772,7 +772,8 @@ describe('.gotoURL', () => {
       } catch (err) {
         expect(err).toHaveProperty('code', 'INSECURE_DOCUMENT_REQUEST');
         expect(err.friendlyMessage).toBeDisplayString(
-          'The URL you have provided does not have valid security credentials. reason 1. reason 2.'
+          'The URL you have provided does not have a valid security certificate. ' +
+          'reason 1. reason 2.'
         );
       }
     });
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index f788bebba..155fd19d3 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -79,8 +79,9 @@ describe('ReportRenderer', () => {
       const output = renderer.renderReport(sampleResults, container);
       assert.ok(output.querySelector('.lh-header-sticky'), 'has a header');
       assert.ok(output.querySelector('.lh-report'), 'has report body');
+      // 3 sets of gauges - one in sticky header, one in scores header, and one in each section.
       assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
-          sampleResults.reportCategories.length * 2, 'renders category gauges');
+          sampleResults.reportCategories.length * 3, 'renders category gauges');
       // no fireworks
       assert.ok(output.querySelector('.score100') === null, 'has no fireworks treatment');
     });
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index b9d4b8d40..4db93baeb 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -60,7 +60,7 @@ describe('ReportUIFeatures', () => {
       };
     };
 
-    global.window = {};
+    global.window = document.window;
     global.window.getComputedStyle = function() {
       return {
         marginTop: '10px',
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 a7548037f..a413fe98d 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -791,7 +791,6 @@
       "description": "Collection of useful page vitals.",
       "score": null,
       "scoreDisplayMode": "informative",
-      "numericValue": 1,
       "details": {
         "type": "debugdata",
         "items": [
@@ -2963,6 +2962,7 @@
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
     "channel": "cli",
+    "budgets": null,
     "locale": "en-US",
     "blockedUrlPatterns": null,
     "additionalTraceCategories": null,