Repository: GoogleChrome/lighthouse
Pull request #8427 (2019-04-19 06:28:42)

feat(lightwallet): Add --budgets-path & Budgets constructor
**Summary:**
Add `--budget-path` & `Budget` class for LightWallet.

**Related Issues/PRs:**
Initial discussion: https://github.com/GoogleChrome/lighthouse/pull/7176
*Will edit as I open the other PRs*

paulirish: > were you planning on wiring up --budgets-path in the CLI to do the actual loading in this PR?

I think it makes sense to add the CLI bits in this PR. That way the handoff of either a path or object will be clear. 


brendankenny: reminder for myself: since this won't (yet) be in the default config, we may need to add some tests to `config-test.js`, e.g. the [idempotent test](https://github.com/GoogleChrome/lighthouse/blob/85be5c6b17aa1a1fcd0ddd9bbbc4fc28aa975df3/lighthouse-core/test/config/config-test.js#L624-L628), but we can investigate that later
patrickhulce: > should it be settings.budgetsJSON? Feels like it should just be settings.budgets.

I have no strong feelings either way. I can see the case being made for either one, so I'll defer to those with stronger feelings :)

> But I don't know how to make those two views harmonious

We start doing "[did you mean](https://www.npmjs.com/package/leven)" for our CLI flags? :)
khempenius: @brendankenny 
I changed everything from `budgets*` to `budget*`. I agree on the name thing - singular seems more intuitive and just easier to keep consistent through the codebase; a `budget.json` having multiple sub-budgets also doesn't seem that strange to me (e.g. nobody use a`configs.js`.)

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/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..a828f3abf 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 */
@@ -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..0a5a5d345 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>');
@@ -835,7 +839,9 @@
 }
 
 .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 +859,8 @@
 }
 
 .lh-scorescale-range {
+  display: flex;
+  align-items: center;
   margin: 0 12px;
   font-family: var(--monospace-font-family);
   white-space: nowrap;
@@ -860,11 +868,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..dbe7fb8fc 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>
 
@@ -513,7 +552,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);
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/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 215f93705..3b4f77214 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"
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/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[];


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/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/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,