Repository: GoogleChrome/lighthouse
Pull request #9873 (2019-10-21 22:18:36)

build(smokehouse): bundle smoke test runner
Bundles smokehouse for consumption in LR / DevTools.

Googlers: see cl/275909869 / [doc](https://docs.google.com/document/d/1oZzphmzd6mdsbjT1TZNzA0OMXFjr90EfPQz6CffHMvs/edit)


brendankenny: > I have a fear that all this firehouse stuff is going to be touchable exclusively by googlers :)

agreed, and that makes the testing system more fragile and harder to change in the future.

Doing some decoupling to make it so non-googlers don't have to care about the tests they can't see was part of the motivation for #9843, and I think we should consider making decisions on the architecture proposed there as a blocker for this.
connorjclark: The first post was updated with the relevant doc (@patrickhulce, nothing has changed since you last reviewed the doc)
patrickhulce: I'm not sure I'm the best reviewer for this PR @connorjclark since I have no insight into the companion consumption code. Any googlers want to swap PRs for this one @exterkamp @paulirish ?
connorjclark: @exterkamp has seen the internal usage, so wdyt shane?

Patch:
diff --git a/.eslintignore b/.eslintignore
index 2005468ac..1f2d7ebda 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -12,3 +12,5 @@
 coverage/**
 
 !.eslintrc.js
+
+lighthouse-core/scripts/legacy-javascript/variants
diff --git a/.gitignore b/.gitignore
index 5d81a6f89..77724bcd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,8 @@ last-run-results.html
 !lighthouse-core/test/fixtures/traces/**/*.trace.json
 !lighthouse-core/test/fixtures/traces/**/*.devtoolslog.json
 
+lighthouse-core/scripts/legacy-javascript/variants
+
 latest-run
 lantern-data
 timings-data
diff --git a/.travis.yml b/.travis.yml
index f21b1c3f1..fea438d02 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -29,8 +29,6 @@ before_script:
   - google-chrome-stable --version
   - yarn build-all
 script:
-  # Fail if any changes were written to source files (ex, from: build/build-cdt-lib.js).
-  - git diff --exit-code
   - yarn bundlesize
   - yarn diff:sample-json
   - yarn lint
@@ -40,10 +38,14 @@ script:
   - yarn test-clients
   - yarn test-viewer
   - yarn test-lantern
+  - yarn test-legacy-javascript
   - yarn test-bundle
   - yarn i18n:checks
   - yarn dogfood-lhci
   - yarn test-docs
+
+  # Fail if any changes were written to source files (ex, from: build/build-cdt-lib.js).
+  - git diff --exit-code
 before_cache:
   # nyc, jest and other projects store files in here. They mess up the travis build cache.
   - rm -rf ./node_modules/.cache/
diff --git a/build/build-extension.js b/build/build-extension.js
index d4735538d..b93d155ca 100644
--- a/build/build-extension.js
+++ b/build/build-extension.js
@@ -13,11 +13,15 @@ const cpy = require('cpy');
 const browserify = require('browserify');
 const path = require('path');
 
+const argv = process.argv.slice(2);
+const browserBrand = argv[0];
+
 const sourceName = 'popup.js';
 const distName = 'popup-bundle.js';
 
-const sourceDir = __dirname + '/../clients/extension';
-const distDir = __dirname + '/../dist/extension';
+const sourceDir = `${__dirname}/../clients/extension`;
+const distDir = `${__dirname}/../dist/extension-${browserBrand}`;
+const packagePath = `${distDir}/../extension-${browserBrand}-package`;
 
 const manifestVersion = require(`${sourceDir}/manifest.json`).version;
 
@@ -30,13 +34,17 @@ async function buildEntryPoint() {
   const bundleStream = browserify(inFile).bundle();
 
   await mkdir(path.dirname(outFile), {recursive: true});
-  return new Promise((resolve, reject) => {
+  await new Promise((resolve, reject) => {
     const writeStream = fs.createWriteStream(outFile);
     writeStream.on('finish', resolve);
     writeStream.on('error', reject);
 
     bundleStream.pipe(writeStream);
   });
+
+  let outCode = fs.readFileSync(outFile, 'utf-8');
+  outCode = outCode.replace('___BROWSER_BRAND___', browserBrand);
+  fs.writeFileSync(outFile, outCode);
 }
 
 /**
@@ -48,7 +56,6 @@ function copyAssets() {
     'styles/**/*.css',
     'images/**/*',
     'manifest.json',
-    '_locales/**', // currently non-functional
   ], distDir, {
     cwd: sourceDir,
     parents: true,
@@ -61,7 +68,6 @@ function copyAssets() {
  * @return {Promise<void>}
  */
 async function packageExtension() {
-  const packagePath = `${distDir}/../extension-package`;
   await mkdir(packagePath, {recursive: true});
 
   return new Promise((resolve, reject) => {
@@ -81,15 +87,12 @@ async function packageExtension() {
 }
 
 async function run() {
-  const argv = process.argv.slice(2);
-  if (argv.includes('package')) {
-    return packageExtension();
-  }
-
   await Promise.all([
     buildEntryPoint(),
     copyAssets(),
   ]);
+
+  await packageExtension();
 }
 
 run();
diff --git a/build/build-smokehouse-bundle.js b/build/build-smokehouse-bundle.js
new file mode 100644
index 000000000..0076ddb2d
--- /dev/null
+++ b/build/build-smokehouse-bundle.js
@@ -0,0 +1,20 @@
+/**
+ * @license Copyright 2020 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 browserify = require('browserify');
+const fs = require('fs');
+
+const distDir = `${__dirname}/../dist`;
+const bundleOutFile = `${distDir}/smokehouse-bundle.js`;
+const smokehouseLibFilename = './lighthouse-cli/test/smokehouse/frontends/lib.js';
+
+browserify(smokehouseLibFilename, {standalone: 'Lighthouse.Smokehouse'})
+  .ignore('./lighthouse-cli/test/smokehouse/lighthouse-runners/cli.js')
+  .bundle((err, src) => {
+    if (err) throw err;
+    fs.writeFileSync(bundleOutFile, src.toString());
+  });
diff --git a/clients/extension/_locales/en/messages.json b/clients/extension/_locales/en/messages.json
deleted file mode 100644
index 8e35fe957..000000000
--- a/clients/extension/_locales/en/messages.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "appName": {
-    "message": "Lighthouse",
-    "description": "Helping you avoid Progressive Web App woes."
-  },
-  "appDescription": {
-    "message": "Lighthouse",
-    "description": "Helping you avoid Progressive Web App woes."
-  }
-}
diff --git a/clients/extension/_locales/en/messages_canary.json b/clients/extension/_locales/en/messages_canary.json
deleted file mode 100644
index 92c4d9fa6..000000000
--- a/clients/extension/_locales/en/messages_canary.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "appName": {
-    "message": "Lighthouse Canary",
-    "description": "Alpha: Helping you avoid Progressive Web App woes."
-  },
-  "appDescription": {
-    "message": "Lighthouse Canary",
-    "description": "Alpha: Helping you avoid Progressive Web App woes."
-  }
-}
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index 218f3a4c0..c537cd5cd 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,14 +1,13 @@
 {
-  "name": "__MSG_appName__",
-  "version": "100.0.0.0",
+  "name": "Lighthouse",
+  "version": "100.0.0.1",
   "minimum_chrome_version": "72",
   "manifest_version": 2,
-  "description": "__MSG_appDescription__",
+  "description": "Lighthouse is an open-source, automated tool for improving the performance, quality, and correctness of your web apps.",
   "icons": {
     "16": "images/lh_favicon_16x16.png",
     "128": "images/lh_logo_128x128.png"
   },
-  "default_locale": "en",
   "permissions": [
     "activeTab",
     "storage"
diff --git a/clients/extension/popup.html b/clients/extension/popup.html
index c099cc440..fae278fa8 100644
--- a/clients/extension/popup.html
+++ b/clients/extension/popup.html
@@ -28,13 +28,21 @@ Unless required by applicable law or agreed to in writing, software distributed
       <span class="psi-disclaimer"><a href="https://developers.google.com/speed/docs/insights/v5/get-started?utm_source=lh-chrome-ext" title="https://developers.google.com/speed/docs/insights/v5/get-started" target="_blank" rel="noopener nofollow">Uses the PSI API</a></span>
       <div class="errormsg"></div>
     </div>
-    <div class="section section--devtools-info">
-      <h2 class="devtools-header">Chrome DevTools</h2>
-      <span class="devtools-note">
-        You can also run Lighthouse via the DevTools Audits panel.
-        <br><br>
-        Shortcut to open DevTools: <span class="devtools-shortcut"><!-- filled dynamically --></span>
-      </span>
+    <div class="section section--secondary-panel">
+      <div class="hidden browser-brand browser-brand--chrome">
+        <h2 class="section--header">Chrome DevTools</h2>
+        <span class="section--description">
+          You can also run Lighthouse via the DevTools Audits panel.
+          <br><br>
+          Shortcut to open DevTools: <span class="devtools-shortcut"><!-- filled dynamically --></span>
+        </span>
+      </div>
+      <div class="hidden browser-brand browser-brand--firefox">
+        <h2 class="section--header">Node CLI</h2>
+        <span class="section--description">
+          You can also run Lighthouse via the <a href="https://github.com/GoogleChrome/lighthouse#using-the-node-cli">Node CLI</a>.
+        </span>
+      </div>
     </div>
   </main>
 
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 47c93c482..fefd0d72e 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -9,6 +9,19 @@ const SettingsController = require('./settings-controller.js');
 
 const VIEWER_URL = 'https://googlechrome.github.io/lighthouse/viewer/';
 const optionsVisibleClass = 'main--options-visible';
+// Replaced with 'chrome' or 'firefox' in the build script.
+/** @type {string} */
+const BROWSER_BRAND = '___BROWSER_BRAND___';
+
+const CHROME_STRINGS = {
+  localhostErrorMessage: 'Use DevTools to audit pages on localhost.',
+};
+
+const FIREFOX_STRINGS = {
+  localhostErrorMessage: 'Use the Lighthouse Node CLI to audit pages on localhost.',
+};
+
+const STRINGS = BROWSER_BRAND === 'chrome' ? CHROME_STRINGS : FIREFOX_STRINGS;
 
 /**
  * Guaranteed context.querySelector. Always returns an element or throws if
@@ -120,7 +133,7 @@ function getSiteUrl() {
 
       const url = new URL(tabs[0].url);
       if (url.hostname === 'localhost') {
-        reject(new Error('Use DevTools to audit pages on localhost.'));
+        reject(new Error(STRINGS.localhostErrorMessage));
       } else if (/^(chrome|about)/.test(url.protocol)) {
         reject(new Error(`Cannot audit ${url.protocol}// pages.`));
       } else {
@@ -134,7 +147,11 @@ function getSiteUrl() {
  * Initializes the popup's state and UI elements.
  */
 async function initPopup() {
-  fillDevToolsShortcut();
+  if (BROWSER_BRAND === 'chrome') {
+    fillDevToolsShortcut();
+  }
+  const browserBrandEl = find(`.browser-brand--${BROWSER_BRAND}`);
+  browserBrandEl.classList.remove('hidden');
 
   const mainEl = find('main');
   const optionsEl = find('.button--configure');
diff --git a/clients/extension/styles/lighthouse.css b/clients/extension/styles/lighthouse.css
index 44e43470e..17e9779eb 100644
--- a/clients/extension/styles/lighthouse.css
+++ b/clients/extension/styles/lighthouse.css
@@ -46,6 +46,10 @@ body {
   padding: 16px;
 }
 
+.hidden {
+  display: none;
+}
+
 .header__icon {
   margin: 2px auto 0;
 }
@@ -102,13 +106,13 @@ body {
   padding-bottom: 10px;
 }
 
-.devtools-header {
+.section-header {
   font-size: var(--font-size);
   font-weight: 500;
   margin-bottom: 5px;
 }
 
-.devtools-note {
+.section--description {
   font-size: 14px;
   color: grey;
 }
diff --git a/docs/releasing.md b/docs/releasing.md
index a3b5b681f..ab8b233ca 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -140,7 +140,7 @@ npm publish
 # Publish viewer.
 yarn deploy-viewer
 
-# Publish the extension (if it changed).
+# Publish the extensions (if it changed).
 open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
 cd dist/extension-package/
 echo "Upload the package zip to CWS dev dashboard..."
@@ -151,6 +151,8 @@ echo "Upload the package zip to CWS dev dashboard..."
 # Select `lighthouse-X.X.X.X.zip`
 # _Publish_ at the bottom
 
+# For Firefox: https://addons.mozilla.org/en-US/developers/addon/google-lighthouse/versions/submit/
+
 # * Tell the world!!! *
 echo "Complete the _Release publicity_ tasks documented above"
 
diff --git a/lighthouse-core/audits/dobetterweb/charset.js b/lighthouse-core/audits/dobetterweb/charset.js
new file mode 100644
index 000000000..266e890f3
--- /dev/null
+++ b/lighthouse-core/audits/dobetterweb/charset.js
@@ -0,0 +1,96 @@
+/**
+ * @license Copyright 2020 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.
+ */
+
+/**
+ * @fileoverview Audits a page to ensure charset it configured properly.
+ * It must be defined within the first 1024 bytes of the HTML document, defined in the HTTP header, or the document source starts with a BOM.
+ *
+ * @see: https://github.com/GoogleChrome/lighthouse/issues/10023
+ */
+'use strict';
+
+const Audit = require('../audit.js');
+const i18n = require('../../lib/i18n/i18n.js');
+const MainResource = require('../../computed/main-resource.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on if the charset is set properly for a page. This title is shown when the charset is defined correctly. Charset defines the character encoding (eg UTF-8) of the page content. */
+  title: 'Properly defines charset',
+  /** Title of a Lighthouse audit that provides detail on if the charset is set properly for a page. This title is shown when the charset meta tag is missing or defined too late in the page. */
+  failureTitle: 'Charset declaration is missing or occurs too late in the HTML',
+  /** Description of a Lighthouse audit that tells the user why the charset needs to be defined early on. */
+  description: 'A character encoding declaration is required. It can be done with a <meta> tag' +
+    'in the first 1024 bytes of the HTML or in the Content-Type HTTP response header. ' +
+    '[Learn more](https://www.w3.org/International/questions/qa-html-encoding-declarations).',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+const CONTENT_TYPE_HEADER = 'content-type';
+// /^[a-zA-Z0-9-_:.()]{2,}$/ matches all known IANA charset names (https://www.iana.org/assignments/character-sets/character-sets.xhtml)
+const IANA_REGEX = /^[a-zA-Z0-9-_:.()]{2,}$/;
+const CHARSET_HTML_REGEX = /<meta[^>]+charset[^<]+>/;
+const CHARSET_HTTP_REGEX = /charset\s*=\s*[a-zA-Z0-9-_:.()]{2,}/;
+
+class CharsetDefined extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'charset',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
+      requiredArtifacts: ['MainDocumentContent', 'URL', 'devtoolsLogs', 'MetaElements'],
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
+   */
+  static async audit(artifacts, context) {
+    const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const mainResource = await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);
+    let isCharsetSet = false;
+    // Check the http header 'content-type' to see if charset is defined there
+    if (mainResource.responseHeaders) {
+      const contentTypeHeader = mainResource.responseHeaders
+        .find(header => header.name.toLowerCase() === CONTENT_TYPE_HEADER);
+
+      if (contentTypeHeader) {
+        isCharsetSet = CHARSET_HTTP_REGEX.test(contentTypeHeader.value);
+      }
+    }
+
+    // Check if there is a BOM byte marker
+    const BOM_FIRSTCHAR = 65279;
+    isCharsetSet = isCharsetSet || artifacts.MainDocumentContent.charCodeAt(0) === BOM_FIRSTCHAR;
+
+    // Check if charset-ish meta tag is defined within the first 1024 characters(~1024 bytes) of the HTML document
+    if (CHARSET_HTML_REGEX.test(artifacts.MainDocumentContent.slice(0, 1024))) {
+      // If so, double-check the DOM attributes, considering both legacy http-equiv and html5 charset styles.
+      isCharsetSet = isCharsetSet || artifacts.MetaElements.some(meta => {
+        return (meta.charset && IANA_REGEX.test(meta.charset)) ||
+          (meta.httpEquiv === 'content-type' &&
+          meta.content &&
+          CHARSET_HTTP_REGEX.test(meta.content));
+      });
+    }
+
+    return {
+      score: Number(isCharsetSet),
+    };
+  }
+}
+
+module.exports = CharsetDefined;
+module.exports.UIStrings = UIStrings;
+module.exports.CHARSET_HTML_REGEX = CHARSET_HTML_REGEX;
+module.exports.CHARSET_HTTP_REGEX = CHARSET_HTTP_REGEX;
+module.exports.IANA_REGEX = IANA_REGEX;
diff --git a/lighthouse-core/audits/legacy-javascript.js b/lighthouse-core/audits/legacy-javascript.js
new file mode 100644
index 000000000..53c795d72
--- /dev/null
+++ b/lighthouse-core/audits/legacy-javascript.js
@@ -0,0 +1,372 @@
+/**
+ * @license Copyright 2020 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';
+
+/**
+ * @fileoverview Identifies polyfills and transforms that should not be present if using module/nomodule pattern.
+ * @see https://docs.google.com/document/d/1ItjJwAd6e0Ts6yMbvh8TN3BBh_sAd58rYE1whnpuxaA/edit Design document
+ * @see https://docs.google.com/spreadsheets/d/1z28Au8wo8-c2UsM2lDVEOJcI3jOkb2c951xEBqzBKCc/edit?usp=sharing Legacy babel transforms / polyfills
+ * ./lighthouse-core/scripts/legacy-javascript - verification tool.
+ */
+
+/** @typedef {{name: string, expression: string}} Pattern */
+/** @typedef {{name: string, line: number, column: number}} PatternMatchResult */
+
+const Audit = require('./audit.js');
+const NetworkRecords = require('../computed/network-records.js');
+const MainResource = require('../computed/main-resource.js');
+const URL = require('../lib/url-shim.js');
+const i18n = require('../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that tells the user about legacy polyfills and transforms used on the page. This is displayed in a list of audit titles that Lighthouse generates. */
+  title: 'Legacy JavaScript',
+  // eslint-disable-next-line max-len
+  // TODO: web.dev article. this codelab is good starting place: https://web.dev/codelab-serve-modern-code/
+  /** Description of a Lighthouse audit that tells the user about old JavaScript that is no longer needed. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  description: 'Polyfills and transforms enable legacy browsers to use new JavaScript features. However, many aren\'t necessary for modern browsers. For your bundled JavaScript, adopt a modern script deployment strategy using module/nomodule feature detection to reduce the amount of code shipped to modern browsers, while retaining support for legacy browsers. [Learn More](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+/**
+ * Takes a list of patterns (consisting of a name identifier and a RegExp expression string)
+ * and returns match results with line / column information for a given code input.
+ */
+class CodePatternMatcher {
+  /**
+   * @param {Pattern[]} patterns
+   */
+  constructor(patterns) {
+    const patternsExpression = patterns.map(pattern => `(${pattern.expression})`).join('|');
+    this.re = new RegExp(`(^\r\n|\r|\n)|${patternsExpression}`, 'g');
+    this.patterns = patterns;
+  }
+
+  /**
+   * @param {string} code
+   * @return {PatternMatchResult[]}
+   */
+  match(code) {
+    // Reset RegExp state.
+    this.re.lastIndex = 0;
+
+    const seen = new Set();
+    /** @type {PatternMatchResult[]} */
+    const matches = [];
+    /** @type {RegExpExecArray | null} */
+    let result;
+    let line = 0;
+    let lineBeginsAtIndex = 0;
+    // Each pattern maps to one subgroup in the generated regex. For each iteration of RegExp.exec,
+    // only one subgroup will be defined. Exec until no more matches.
+    while ((result = this.re.exec(code)) !== null) {
+      // Discard first value in `result` - it's just the entire match.
+      const captureGroups = result.slice(1);
+      // isNewline - truthy if matching a newline, used to track the line number.
+      // `patternExpressionMatches` maps to each possible pattern in `this.patterns`.
+      // Only one of [isNewline, ...patternExpressionMatches] is ever truthy.
+      const [isNewline, ...patternExpressionMatches] = captureGroups;
+      if (isNewline) {
+        line++;
+        lineBeginsAtIndex = result.index + 1;
+        continue;
+      }
+      const pattern = this.patterns[patternExpressionMatches.findIndex(Boolean)];
+
+      // Don't report more than one instance of a pattern for this code.
+      // Would result in multiple matches for the same pattern, ex: if both '='
+      // and 'Object.defineProperty' are used conditionally based on feature detection.
+      // Would also result in many matches for transform patterns.
+      if (seen.has(pattern)) {
+        continue;
+      }
+      seen.add(pattern);
+
+      matches.push({
+        name: pattern.name,
+        line,
+        column: result.index - lineBeginsAtIndex,
+      });
+    }
+
+    return matches;
+  }
+}
+
+class LegacyJavascript extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'legacy-javascript',
+      scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
+      description: str_(UIStrings.description),
+      title: str_(UIStrings.title),
+      requiredArtifacts: ['devtoolsLogs', 'ScriptElements', 'URL'],
+    };
+  }
+
+  /**
+   * @param {string?} object
+   * @param {string} property
+   */
+  static buildPolyfillExpression(object, property) {
+    const qt = (/** @type {string} */ token) =>
+      `['"]${token}['"]`; // don't worry about matching string delims
+
+    let expression = '';
+
+    if (object) {
+      // String.prototype.startsWith =
+      expression += `${object}\\.${property}\\s?=[^=]`;
+    } else {
+      // Promise =
+      // window.Promise =// Promise =Z
+      // but not: SomePromise =
+      expression += `(?:window\\.|[\\s;]+)${property}\\s?=[^=]`;
+    }
+
+    // String.prototype['startsWith'] =
+    if (object) {
+      expression += `|${object}\\[${qt(property)}\\]\\s?=[^=]`;
+    }
+
+    // Object.defineProperty(String.prototype, 'startsWith'
+    expression += `|defineProperty\\(${object || 'window'},\\s?${qt(property)}`;
+
+    // core-js
+    if (object) {
+      const objectWithoutPrototype = object.replace('.prototype', '');
+      // e(e.S,"Object",{values
+      // Minified + mangled pattern found in CDN babel-polyfill.
+      // see https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.min.js
+      // TODO: perhaps this is the wrong place to check for a CDN polyfill. Remove?
+      // expression += `|;e\\([^,]+,${qt(objectWithoutPrototype)},{${property}:`;
+
+      // Minified pattern.
+      // $export($export.S,"Date",{now:function
+      expression += `|\\$export\\([^,]+,${qt(objectWithoutPrototype)},{${property}:`;
+    } else {
+      // WeakSet, etc.
+      expression += `|function ${property}\\(`;
+    }
+
+    return expression;
+  }
+
+  /**
+   * @return {Pattern[]}
+   */
+  static getPolyfillPatterns() {
+    return [
+      'Array.fill',
+      'Array.from',
+      'Array.isArray',
+      'Array.of',
+      'Array.prototype.filter',
+      'Array.prototype.find',
+      'Array.prototype.findIndex',
+      'Array.prototype.forEach',
+      'Array.prototype.includes',
+      'Array.prototype.lastIndexOf',
+      'Array.prototype.map',
+      'Array.prototype.reduce',
+      'Array.prototype.reduceRight',
+      'Array.prototype.some',
+      'ArrayBuffer',
+      'DataView',
+      'Date.now',
+      'Date.prototype.toISOString',
+      'Date.prototype.toJSON',
+      'Date.prototype.toString',
+      'Float32Array',
+      'Float64Array',
+      'Function.prototype.name',
+      'Int16Array',
+      'Int32Array',
+      'Int8Array',
+      'Map',
+      'Number.isInteger',
+      'Number.isSafeInteger',
+      'Number.parseFloat',
+      'Number.parseInt',
+      'Object.assign',
+      'Object.create',
+      'Object.defineProperties',
+      'Object.defineProperty',
+      'Object.entries',
+      'Object.freeze',
+      'Object.getOwnPropertyDescriptor',
+      'Object.getOwnPropertyDescriptors',
+      'Object.getOwnPropertyNames',
+      'Object.getPrototypeOf',
+      'Object.isExtensible',
+      'Object.isFrozen',
+      'Object.isSealed',
+      'Object.keys',
+      'Object.preventExtensions',
+      'Object.seal',
+      'Object.setPrototypeOf',
+      'Object.values',
+      'Promise',
+      'Reflect.apply',
+      'Reflect.construct',
+      'Reflect.defineProperty',
+      'Reflect.deleteProperty',
+      'Reflect.get',
+      'Reflect.getOwnPropertyDescriptor',
+      'Reflect.getPrototypeOf',
+      'Reflect.has',
+      'Reflect.isExtensible',
+      'Reflect.ownKeys',
+      'Reflect.preventExtensions',
+      'Reflect.set',
+      'Reflect.setPrototypeOf',
+      'Set',
+      'String.fromCodePoint',
+      'String.prototype.codePointAt',
+      'String.prototype.endsWith',
+      'String.prototype.includes',
+      'String.prototype.padEnd',
+      'String.prototype.padStart',
+      'String.prototype.repeat',
+      'String.prototype.startsWith',
+      'String.prototype.trim',
+      'String.raw',
+      'Uint16Array',
+      'Uint32Array',
+      'Uint8Array',
+      'Uint8ClampedArray',
+      'WeakMap',
+      'WeakSet',
+    ].map(polyfillName => {
+      const parts = polyfillName.split('.');
+      const object = parts.length > 1 ? parts.slice(0, parts.length - 1).join('.') : null;
+      const property = parts[parts.length - 1];
+      return {
+        name: polyfillName,
+        expression: this.buildPolyfillExpression(object, property),
+      };
+    });
+  }
+
+  /**
+   * @return {Pattern[]}
+   */
+  static getTransformPatterns() {
+    return [
+      {
+        name: '@babel/plugin-transform-classes',
+        expression: 'Cannot call a class as a function',
+      },
+      {
+        name: '@babel/plugin-transform-regenerator',
+        expression: /regeneratorRuntime\.a?wrap/.source,
+      },
+      {
+        name: '@babel/plugin-transform-spread',
+        expression: /\.apply\(void 0,\s?_toConsumableArray/.source,
+      },
+    ];
+  }
+
+  /**
+   * Returns a collection of match results grouped by script url and with a mapping
+   * to determine the order in which the matches were discovered.
+   *
+   * @param {CodePatternMatcher} matcher
+   * @param {LH.GathererArtifacts['ScriptElements']} scripts
+   * @param {LH.Artifacts.NetworkRequest[]} networkRecords
+   * @return {Map<string, PatternMatchResult[]>}
+   */
+  static detectCodePatternsAcrossScripts(matcher, scripts, networkRecords) {
+    /** @type {Map<string, PatternMatchResult[]>} */
+    const urlToMatchResults = new Map();
+
+    for (const {requestId, content} of Object.values(scripts)) {
+      if (!content) continue;
+      const networkRecord = networkRecords.find(record => record.requestId === requestId);
+      if (!networkRecord) continue;
+      const matches = matcher.match(content);
+      if (!matches.length) continue;
+      urlToMatchResults.set(networkRecord.url, matches);
+    }
+
+    return urlToMatchResults;
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
+   */
+  static async audit(artifacts, context) {
+    const devtoolsLog = artifacts.devtoolsLogs[LegacyJavascript.DEFAULT_PASS];
+    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
+    const mainResource = await MainResource.request({
+      URL: artifacts.URL,
+      devtoolsLog,
+    }, context);
+
+    /** @type {Array<{url: string, signals: string[], locations: LH.Audit.Details.SourceLocationValue[]}>} */
+    const tableRows = [];
+    let signalCount = 0;
+
+    // TODO(cjamcl): Use SourceMaps, and only pattern match if maps are not available.
+    const matcher = new CodePatternMatcher([
+      ...this.getPolyfillPatterns(),
+      ...this.getTransformPatterns(),
+    ]);
+
+    const urlToMatchResults =
+      this.detectCodePatternsAcrossScripts(matcher, artifacts.ScriptElements, networkRecords);
+    urlToMatchResults.forEach((matches, url) => {
+      /** @type {typeof tableRows[number]} */
+      const row = {url, signals: [], locations: []};
+      for (const match of matches) {
+        const {name, line, column} = match;
+        row.signals.push(name);
+        row.locations.push({
+          type: 'source-location',
+          url,
+          line,
+          column,
+          urlProvider: 'network',
+        });
+      }
+      tableRows.push(row);
+      signalCount += row.signals.length;
+    });
+
+    /** @type {LH.Audit.Details.Table['headings']} */
+    const headings = [
+      /* eslint-disable max-len */
+      {key: 'url', itemType: 'url', subRows: {key: 'locations', itemType: 'source-location'}, text: str_(i18n.UIStrings.columnURL)},
+      {key: null, itemType: 'code', subRows: {key: 'signals'}, text: ''},
+      /* eslint-enable max-len */
+    ];
+    const details = Audit.makeTableDetails(headings, tableRows);
+
+    // Only fail if first party code has legacy code.
+    // TODO(cjamcl): Use third-party-web.
+    const foundSignalInFirstPartyCode = tableRows.some(row => {
+      return URL.rootDomainsMatch(row.url, mainResource.url);
+    });
+    return {
+      score: foundSignalInFirstPartyCode ? 0 : 1,
+      notApplicable: !foundSignalInFirstPartyCode,
+      extendedInfo: {
+        signalCount,
+      },
+      details,
+    };
+  }
+}
+
+module.exports = LegacyJavascript;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 28e8d3251..93e30cb6d 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -54,7 +54,7 @@ function assertValidPasses(passes, audits) {
     return;
   }
 
-  const requiredGatherers = Config.getGatherersNeededByAudits(audits);
+  const requestedGatherers = Config.getGatherersRequestedByAudits(audits);
   // Base artifacts are provided by GatherRunner, so start foundGatherers with them.
   const foundGatherers = new Set(BASE_ARTIFACT_NAMES);
 
@@ -69,7 +69,7 @@ function assertValidPasses(passes, audits) {
     pass.gatherers.forEach(gathererDefn => {
       const gatherer = gathererDefn.instance;
       foundGatherers.add(gatherer.name);
-      const isGatherRequiredByAudits = requiredGatherers.has(gatherer.name);
+      const isGatherRequiredByAudits = requestedGatherers.has(gatherer.name);
       if (!isGatherRequiredByAudits) {
         const msg = `${gatherer.name} gatherer requested, however no audit requires it.`;
         log.warn('config', msg);
@@ -573,10 +573,10 @@ class Config {
         requestedAuditNames.has(auditDefn.implementation.meta.id));
 
     // 3. Resolve which gatherers will need to run
-    const requiredGathererIds = Config.getGatherersNeededByAudits(audits);
+    const requestedGathererIds = Config.getGatherersRequestedByAudits(audits);
 
     // 4. Filter to only the neccessary passes
-    const passes = Config.generatePassesNeededByGatherers(config.passes, requiredGathererIds);
+    const passes = Config.generatePassesNeededByGatherers(config.passes, requestedGathererIds);
 
     config.categories = categories;
     config.audits = audits;
@@ -679,40 +679,45 @@ class Config {
   }
 
   /**
-   * From some requested audits, return names of all required artifacts
+   * From some requested audits, return names of all required and optional artifacts
    * @param {Config['audits']} audits
    * @return {Set<string>}
    */
-  static getGatherersNeededByAudits(audits) {
+  static getGatherersRequestedByAudits(audits) {
     // It's possible we weren't given any audits (but existing audit results), in which case
     // there is no need to do any work here.
     if (!audits) {
       return new Set();
     }
 
-    return audits.reduce((list, auditDefn) => {
-      auditDefn.implementation.meta.requiredArtifacts.forEach(artifact => list.add(artifact));
-      return list;
-    }, new Set());
+    const gatherers = new Set();
+    for (const auditDefn of audits) {
+      const {requiredArtifacts, __internalOptionalArtifacts} = auditDefn.implementation.meta;
+      requiredArtifacts.forEach(artifact => gatherers.add(artifact));
+      if (__internalOptionalArtifacts) {
+        __internalOptionalArtifacts.forEach(artifact => gatherers.add(artifact));
+      }
+    }
+    return gatherers;
   }
 
   /**
-   * Filters to only required passes and gatherers, returning a new passes array.
+   * Filters to only requested passes and gatherers, returning a new passes array.
    * @param {Config['passes']} passes
-   * @param {Set<string>} requiredGatherers
+   * @param {Set<string>} requestedGatherers
    * @return {Config['passes']}
    */
-  static generatePassesNeededByGatherers(passes, requiredGatherers) {
+  static generatePassesNeededByGatherers(passes, requestedGatherers) {
     if (!passes) {
       return null;
     }
 
-    const auditsNeedTrace = requiredGatherers.has('traces');
+    const auditsNeedTrace = requestedGatherers.has('traces');
     const filteredPasses = passes.map(pass => {
       // remove any unncessary gatherers from within the passes
       pass.gatherers = pass.gatherers.filter(gathererDefn => {
         const gatherer = gathererDefn.instance;
-        return requiredGatherers.has(gatherer.name);
+        return requestedGatherers.has(gatherer.name);
       });
 
       // disable the trace if no audit requires a trace
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 15eba06b4..562345a2f 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -288,6 +288,7 @@ const defaultConfig = {
     'byte-efficiency/efficient-animated-content',
     'dobetterweb/appcache-manifest',
     'dobetterweb/doctype',
+    'dobetterweb/charset',
     'dobetterweb/dom-size',
     'dobetterweb/external-anchors-use-rel-noopener',
     'dobetterweb/geolocation-on-start',
@@ -507,6 +508,7 @@ const defaultConfig = {
         {id: 'external-anchors-use-rel-noopener', weight: 1},
         {id: 'geolocation-on-start', weight: 1},
         {id: 'doctype', weight: 1},
+        {id: 'charset', weight: 1},
         {id: 'no-vulnerable-libraries', weight: 1},
         {id: 'js-libraries', weight: 0},
         {id: 'notification-on-start', weight: 1},
diff --git a/lighthouse-core/config/experimental-config.js b/lighthouse-core/config/experimental-config.js
index 134d7c487..d45473374 100644
--- a/lighthouse-core/config/experimental-config.js
+++ b/lighthouse-core/config/experimental-config.js
@@ -20,16 +20,14 @@ const config = {
     ],
   }],
   audits: [
-    // About to be added.
-    // 'bundle-duplication',
+    'legacy-javascript',
   ],
   // @ts-ignore: `title` is required in CategoryJson. setting to the same value as the default
   // config is awkward - easier to omit the property here. Will defer to default config.
   categories: {
     'performance': {
       auditRefs: [
-        // About to be added.
-        // {id: 'bundle-duplication', weight: 0, group: 'load-opportunities'},
+        {id: 'legacy-javascript', weight: 0, group: 'diagnostics'},
       ],
     },
   },
diff --git a/lighthouse-core/gather/gatherers/meta-elements.js b/lighthouse-core/gather/gatherers/meta-elements.js
index ad93d84e5..695f65266 100644
--- a/lighthouse-core/gather/gatherers/meta-elements.js
+++ b/lighthouse-core/gather/gatherers/meta-elements.js
@@ -26,6 +26,8 @@ class MetaElements extends Gatherer {
           name: meta.name.toLowerCase(),
           content: meta.content,
           property: meta.attributes.property ? meta.attributes.property.value : undefined,
+          httpEquiv: meta.httpEquiv ? meta.httpEquiv.toLowerCase() : undefined,
+          charset: meta.attributes.charset ? meta.attributes.charset.value : undefined,
         };
       });
     })()`, {useIsolation: true});
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index b8c437e5f..698433394 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -554,6 +554,15 @@
   "lighthouse-core/audits/dobetterweb/appcache-manifest.js | title": {
     "message": "Avoids Application Cache"
   },
+  "lighthouse-core/audits/dobetterweb/charset.js | description": {
+    "message": "A character encoding declaration is required. It can be done with a <meta> tagin the first 1024 bytes of the HTML or in the Content-Type HTTP response header. [Learn more](https://www.w3.org/International/questions/qa-html-encoding-declarations)."
+  },
+  "lighthouse-core/audits/dobetterweb/charset.js | failureTitle": {
+    "message": "Charset declaration is missing or occurs too late in the HTML"
+  },
+  "lighthouse-core/audits/dobetterweb/charset.js | title": {
+    "message": "Properly defines charset"
+  },
   "lighthouse-core/audits/dobetterweb/doctype.js | description": {
     "message": "Specifying a doctype prevents the browser from switching to quirks-mode. [Learn more](https://web.dev/doctype)."
   },
@@ -788,6 +797,12 @@
   "lighthouse-core/audits/is-on-https.js | title": {
     "message": "Uses HTTPS"
   },
+  "lighthouse-core/audits/legacy-javascript.js | description": {
+    "message": "Polyfills and transforms enable legacy browsers to use new JavaScript features. However, many aren't necessary for modern browsers. For your bundled JavaScript, adopt a modern script deployment strategy using module/nomodule feature detection to reduce the amount of code shipped to modern browsers, while retaining support for legacy browsers. [Learn More](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)"
+  },
+  "lighthouse-core/audits/legacy-javascript.js | title": {
+    "message": "Legacy JavaScript"
+  },
   "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
     "message": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://web.dev/load-fast-enough-for-pwa)."
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 0d27fdd27..51eaff935 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -554,6 +554,15 @@
   "lighthouse-core/audits/dobetterweb/appcache-manifest.js | title": {
     "message": "Âv́ôíd̂ś Âṕp̂ĺîćât́îón̂ Ćâćĥé"
   },
+  "lighthouse-core/audits/dobetterweb/charset.js | description": {
+    "message": "Â ćĥár̂áĉt́êŕ êńĉód̂ín̂ǵ d̂éĉĺâŕât́îón̂ íŝ ŕêq́ûír̂éd̂. Ít̂ ćâń b̂é d̂ón̂é ŵít̂h́ â <ḿêt́â> t́âǵîń t̂h́ê f́îŕŝt́ 1024 b̂ýt̂éŝ óf̂ t́ĥé ĤT́M̂Ĺ ôŕ îń t̂h́ê Ćôńt̂én̂t́-T̂ýp̂é ĤT́T̂Ṕ r̂éŝṕôńŝé ĥéâd́êŕ. [L̂éâŕn̂ ḿôŕê](https://www.w3.org/International/questions/qa-html-encoding-declarations)."
+  },
+  "lighthouse-core/audits/dobetterweb/charset.js | failureTitle": {
+    "message": "Ĉh́âŕŝét̂ d́êćl̂ár̂át̂íôń îś m̂íŝśîńĝ ór̂ óĉćûŕŝ t́ôó l̂át̂é îń t̂h́ê H́T̂ḾL̂"
+  },
+  "lighthouse-core/audits/dobetterweb/charset.js | title": {
+    "message": "P̂ŕôṕêŕl̂ý d̂éf̂ín̂éŝ ćĥár̂śêt́"
+  },
   "lighthouse-core/audits/dobetterweb/doctype.js | description": {
     "message": "Ŝṕêćîf́ŷín̂ǵ â d́ôćt̂ýp̂é p̂ŕêv́êńt̂ś t̂h́ê b́r̂óŵśêŕ f̂ŕôḿ ŝẃît́ĉh́îńĝ t́ô q́ûír̂ḱŝ-ḿôd́ê. [Ĺêár̂ń m̂ór̂é](https://web.dev/doctype)."
   },
@@ -788,6 +797,12 @@
   "lighthouse-core/audits/is-on-https.js | title": {
     "message": "Ûśêś ĤT́T̂ṔŜ"
   },
+  "lighthouse-core/audits/legacy-javascript.js | description": {
+    "message": "P̂ól̂ýf̂íl̂ĺŝ án̂d́ t̂ŕâńŝf́ôŕm̂ś êńâb́l̂é l̂éĝáĉý b̂ŕôẃŝér̂ś t̂ó ûśê ńêẃ Ĵáv̂áŜćr̂íp̂t́ f̂éât́ûŕêś. Ĥóŵév̂ér̂, ḿâńŷ ár̂én̂'t́ n̂éĉéŝśâŕŷ f́ôŕ m̂ód̂ér̂ń b̂ŕôẃŝér̂ś. F̂ór̂ ýôúr̂ b́ûńd̂ĺêd́ Ĵáv̂áŜćr̂íp̂t́, âd́ôṕt̂ á m̂ód̂ér̂ń ŝćr̂íp̂t́ d̂ép̂ĺôým̂én̂t́ ŝt́r̂át̂éĝý ûśîńĝ ḿôd́ûĺê/ńôḿôd́ûĺê f́êát̂úr̂é d̂ét̂éĉt́îón̂ t́ô ŕêd́ûćê t́ĥé âḿôún̂t́ ôf́ ĉód̂é ŝh́îṕp̂éd̂ t́ô ḿôd́êŕn̂ b́r̂óŵśêŕŝ, ẃĥíl̂é r̂ét̂áîńîńĝ śûṕp̂ór̂t́ f̂ór̂ ĺêǵâćŷ b́r̂óŵśêŕŝ. [Ĺêár̂ń M̂ór̂é](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)"
+  },
+  "lighthouse-core/audits/legacy-javascript.js | title": {
+    "message": "L̂éĝáĉý Ĵáv̂áŜćr̂íp̂t́"
+  },
   "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
     "message": "Â f́âśt̂ ṕâǵê ĺôád̂ óv̂ér̂ á ĉél̂ĺûĺâŕ n̂ét̂ẃôŕk̂ én̂śûŕêś â ǵôód̂ ḿôb́îĺê úŝér̂ éx̂ṕêŕîén̂ćê. [Ĺêár̂ń m̂ór̂é](https://web.dev/load-fast-enough-for-pwa)."
   },
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 3f9a1d37a..582b7dcea 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -319,6 +319,11 @@ class DetailsRenderer {
     if (heading.subRows) {
       // @ts-ignore: It's ok that there is no text.
       subRows = this._getCanonicalizedHeading(heading.subRows);
+      if (!subRows.key) {
+        // eslint-disable-next-line no-console
+        console.warn('key should not be null');
+      }
+      subRows = {...subRows, key: subRows.key || ''};
     }
 
     return {
@@ -374,10 +379,21 @@ class DetailsRenderer {
       for (const heading of headings) {
         const valueFragment = this._dom.createFragment();
 
-        const value = row[heading.key];
-        const valueElement =
-          value !== undefined && !Array.isArray(value) && this._renderTableValue(value, heading);
-        if (valueElement) valueFragment.appendChild(valueElement);
+        if (heading.key === null && !heading.subRows) {
+          // eslint-disable-next-line no-console
+          console.warn('A header with a null `key` should define `subRows`.');
+        }
+
+        if (heading.key === null) {
+          const emptyElement = this._dom.createElement('div');
+          emptyElement.innerHTML = '&nbsp;';
+          valueFragment.appendChild(emptyElement);
+        } else {
+          const value = row[heading.key];
+          const valueElement =
+            value !== undefined && !Array.isArray(value) && this._renderTableValue(value, heading);
+          if (valueElement) valueFragment.appendChild(valueElement);
+        }
 
         if (heading.subRows) {
           const subRowsHeading = {
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 50e543f0c..5ea22235a 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -216,6 +216,11 @@ class ReportUIFeatures {
       // This audit deals explicitly with third party resources.
       'uses-rel-preconnect',
     ];
+    // Some audits should hide third party by default.
+    const thirdPartyFilterAuditHideByDefault = [
+      // Only first party resources are actionable.
+      'legacy-javascript',
+    ];
 
     // Get all tables with a text url column.
     /** @type {Array<HTMLTableElement>} */
@@ -230,8 +235,8 @@ class ReportUIFeatures {
       });
 
     tablesWithUrls.forEach((tableEl, index) => {
-      const urlItems = this._getUrlItems(tableEl);
-      const thirdPartyRows = this._getThirdPartyRows(tableEl, urlItems, this.json.finalUrl);
+      const rowEls = getTableRows(tableEl);
+      const thirdPartyRows = this._getThirdPartyRows(rowEls, this.json.finalUrl);
 
       // create input box
       const filterTemplate = this._dom.cloneTemplate('#tmpl-lh-3p-filter', this._templateContext);
@@ -262,43 +267,52 @@ class ReportUIFeatures {
       this._dom.find('.lh-3p-ui-string', filterTemplate).textContent =
           Util.i18n.strings.thirdPartyResourcesLabel;
 
+      const allThirdParty = thirdPartyRows.size === rowEls.length;
+      const allFirstParty = !thirdPartyRows.size;
+
       // If all or none of the rows are 3rd party, disable the checkbox.
-      if (thirdPartyRows.size === urlItems.length || !thirdPartyRows.size) {
+      if (allThirdParty || allFirstParty) {
         filterInput.disabled = true;
-        filterInput.checked = thirdPartyRows.size === urlItems.length;
+        filterInput.checked = allThirdParty;
       }
 
-      // Finally, add checkbox to the DOM.
+      // Add checkbox to the DOM.
       if (!tableEl.parentNode) return; // Keep tsc happy.
       tableEl.parentNode.insertBefore(filterTemplate, tableEl);
+
+      // Hide third-party rows for some audits by default.
+      const containingAudit = tableEl.closest('.lh-audit');
+      if (!containingAudit) throw new Error('.lh-table not within audit');
+      if (thirdPartyFilterAuditHideByDefault.includes(containingAudit.id) && !allThirdParty) {
+        filterInput.click();
+      }
     });
   }
 
   /**
    * From a table with URL entries, finds the rows containing third-party URLs
    * and returns a Map of those rows, mapping from row index to row Element.
-   * @param {HTMLTableElement} el
+   * @param {HTMLElement[]} rowEls
    * @param {string} finalUrl
-   * @param {Array<HTMLElement>} urlItems
-   * @return {Map<number, HTMLTableRowElement>}
+   * @return {Map<number, HTMLElement>}
    */
-  _getThirdPartyRows(el, urlItems, finalUrl) {
+  _getThirdPartyRows(rowEls, finalUrl) {
+    /** @type {Map<number, HTMLElement>} */
+    const thirdPartyRows = new Map();
     const finalUrlRootDomain = Util.getRootDomain(finalUrl);
 
-    /** @type {Map<number, HTMLTableRowElement>} */
-    const thirdPartyRows = new Map();
-    for (const urlItem of urlItems) {
+    rowEls.forEach((rowEl, rowPosition) => {
+      /** @type {HTMLElement|null} */
+      const urlItem = rowEl.querySelector('.lh-text__url');
+      if (!urlItem) return;
+
       const datasetUrl = urlItem.dataset.url;
-      if (!datasetUrl) continue;
+      if (!datasetUrl) return;
       const isThirdParty = Util.getRootDomain(datasetUrl) !== finalUrlRootDomain;
-      if (!isThirdParty) continue;
+      if (!isThirdParty) return;
 
-      const urlRowEl = urlItem.closest('tr');
-      if (urlRowEl) {
-        const rowPosition = getTableRows(el).indexOf(urlRowEl);
-        thirdPartyRows.set(rowPosition, urlRowEl);
-      }
-    }
+      thirdPartyRows.set(Number(rowPosition), rowEl);
+    });
 
     return thirdPartyRows;
   }
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 5ebc13306..6e0858f63 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1410,6 +1410,9 @@
   margin-left: 20px;
   color: var(--color-gray-700);
 }
+.lh-sub-row {
+  margin-bottom: 0;
+}
 
 /* Chevron
    https://codepen.io/paulirish/pen/LmzEmK
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 15d242580..56ba0954d 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -328,17 +328,19 @@ class Runner {
         ...sharedAuditContext,
       };
 
-      // Only pass the declared `requiredArtifacts` to the audit
+      // Only pass the declared required and optional artifacts to the audit
       // The type is masquerading as `LH.Artifacts` but will only contain a subset of the keys
       // to prevent consumers from unnecessary type assertions.
-      const requiredArtifacts = audit.meta.requiredArtifacts
-        .reduce((requiredArtifacts, artifactName) => {
-          const requiredArtifact = artifacts[artifactName];
+      const requestedArtifacts = audit.meta.requiredArtifacts
+        .concat(audit.meta.__internalOptionalArtifacts || []);
+      const narrowedArtifacts = requestedArtifacts
+        .reduce((narrowedArtifacts, artifactName) => {
+          const requestedArtifact = artifacts[artifactName];
           // @ts-ignore tsc can't yet express that artifactName is only a single type in each iteration, not a union of types.
-          requiredArtifacts[artifactName] = requiredArtifact;
-          return requiredArtifacts;
+          narrowedArtifacts[artifactName] = requestedArtifact;
+          return narrowedArtifacts;
         }, /** @type {LH.Artifacts} */ ({}));
-      const product = await audit.audit(requiredArtifacts, auditContext);
+      const product = await audit.audit(narrowedArtifacts, auditContext);
       auditResult = Audit.generateAuditResult(audit, product);
     } catch (err) {
       // Log error if it hasn't already been logged above.
diff --git a/lighthouse-core/scripts/legacy-javascript/README.md b/lighthouse-core/scripts/legacy-javascript/README.md
new file mode 100644
index 000000000..88c8cd450
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/README.md
@@ -0,0 +1,21 @@
+# `legacy-javascript` Validation Tool
+
+Run:
+
+```sh
+yarn install
+node run.js
+# STAGE=build|audit|all to just build the audits or run LegacyJavascript on them. Defaults to both (`all`).
+```
+
+`summary-signals.json` - summarizes the signals that LegacyJavascript finds for each variant. Variants in `variantsMissingSignals` (excluding `core-js-3-preset-env-esmodules/true`) signify a lack of detection for that variant. Full coverage isn't necessary.
+
+`summary-sizes.json` - lists the size of each minified variant. Useful for understanding how many bytes each polyfill / transform adds.
+
+## Future Work
+
+* Use real apps to see how over-transpiling affects real code. Necessary for making an opprotunity.
+
+## Notes
+
+Digging into core-js: https://gist.github.com/connorjclark/cc583554ff07cba7cdc416c06721fd6a
diff --git a/lighthouse-core/scripts/legacy-javascript/main.js b/lighthouse-core/scripts/legacy-javascript/main.js
new file mode 100644
index 000000000..e4af66b2f
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/main.js
@@ -0,0 +1,9 @@
+// @ts-nocheck
+/* eslint-disable */
+
+class MyTestClass {};
+async function reg(...args) {
+  await 1;
+}
+const spread = [...[1,2,3], 3, 2, 1];
+reg(...spread);
diff --git a/lighthouse-core/scripts/legacy-javascript/package.json b/lighthouse-core/scripts/legacy-javascript/package.json
new file mode 100644
index 000000000..8a810079d
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "legacy-javascript",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "@babel/cli": "^7.8.4",
+    "@babel/core": "^7.8.4",
+    "@babel/preset-env": "^7.8.4",
+    "browserify": "^16.5.0",
+    "glob": "^7.1.6",
+    "terser": "^4.6.3"
+  }
+}
diff --git a/lighthouse-core/scripts/legacy-javascript/run.js b/lighthouse-core/scripts/legacy-javascript/run.js
new file mode 100644
index 000000000..dc6d1f80a
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/run.js
@@ -0,0 +1,321 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+const fs = require('fs');
+const glob = require('glob');
+const {execFileSync} = require('child_process');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+const networkRecordsToDevtoolsLog = require('../../test/network-records-to-devtools-log.js');
+const VARIANT_DIR = `${__dirname}/variants`;
+
+// build, audit, all.
+const STAGE = process.env.STAGE || 'all';
+
+const mainCode = fs.readFileSync(`${__dirname}/main.js`, 'utf-8');
+
+const plugins = LegacyJavascript.getTransformPatterns().map(pattern => pattern.name);
+
+// TODO(cjamcl): Get this from `LegacyJavascript`. Data isn't structured ideally yet, but should be
+// done when SourceMap support is in.
+const polyfills = [
+  'es6.array.copy-within',
+  'es6.array.every',
+  'es6.array.fill',
+  'es6.array.filter',
+  'es6.array.find-index',
+  'es6.array.find',
+  'es6.array.for-each',
+  'es6.array.from',
+  'es6.array.index-of',
+  'es6.array.is-array',
+  'es6.array.iterator',
+  'es6.array.last-index-of',
+  'es6.array.map',
+  'es6.array.of',
+  'es6.array.reduce-right',
+  'es6.array.reduce',
+  'es6.array.some',
+  'es6.array.species',
+  'es6.date.now',
+  'es6.date.to-iso-string',
+  'es6.date.to-json',
+  'es6.date.to-primitive',
+  'es6.date.to-string',
+  'es6.function.bind',
+  'es6.function.has-instance',
+  'es6.function.name',
+  'es6.map',
+  'es6.math.acosh',
+  'es6.math.asinh',
+  'es6.math.atanh',
+  'es6.math.cbrt',
+  'es6.math.clz32',
+  'es6.math.cosh',
+  'es6.math.expm1',
+  'es6.math.fround',
+  'es6.math.hypot',
+  'es6.math.imul',
+  'es6.math.log10',
+  'es6.math.log1p',
+  'es6.math.log2',
+  'es6.math.sign',
+  'es6.math.sinh',
+  'es6.math.tanh',
+  'es6.math.trunc',
+  'es6.number.constructor',
+  'es6.number.epsilon',
+  'es6.number.is-integer',
+  'es6.number.is-safe-integer',
+  'es6.number.max-safe-integer',
+  'es6.number.min-safe-integer',
+  'es6.number.parse-float',
+  'es6.number.parse-int',
+  'es6.object.assign',
+  'es6.object.create',
+  'es6.object.define-properties',
+  'es6.object.define-property',
+  'es6.object.freeze',
+  'es6.object.get-own-property-descriptor',
+  'es6.object.get-own-property-names',
+  'es6.object.get-prototype-of',
+  'es6.object.is-extensible',
+  'es6.object.is-frozen',
+  'es6.object.is-sealed',
+  'es6.object.keys',
+  'es6.object.prevent-extensions',
+  'es6.object.seal',
+  'es6.object.set-prototype-of',
+  'es6.object.to-string',
+  'es6.promise',
+  'es6.reflect.apply',
+  'es6.reflect.construct',
+  'es6.reflect.define-property',
+  'es6.reflect.delete-property',
+  'es6.reflect.get-own-property-descriptor',
+  'es6.reflect.get-prototype-of',
+  'es6.reflect.get',
+  'es6.reflect.has',
+  'es6.reflect.is-extensible',
+  'es6.reflect.own-keys',
+  'es6.reflect.prevent-extensions',
+  'es6.reflect.set-prototype-of',
+  'es6.reflect.set',
+  'es6.set',
+  'es6.string.code-point-at',
+  'es6.string.ends-with',
+  'es6.string.from-code-point',
+  'es6.string.includes',
+  'es6.string.iterator',
+  'es6.string.raw',
+  'es6.string.repeat',
+  'es6.string.starts-with',
+  'es6.string.trim',
+  'es6.typed.array-buffer',
+  'es6.typed.data-view',
+  'es6.typed.float32-array',
+  'es6.typed.float64-array',
+  'es6.typed.int16-array',
+  'es6.typed.int32-array',
+  'es6.typed.int8-array',
+  'es6.typed.uint16-array',
+  'es6.typed.uint32-array',
+  'es6.typed.uint8-array',
+  'es6.typed.uint8-clamped-array',
+  'es6.weak-map',
+  'es6.weak-set',
+  'es7.array.includes',
+  'es7.object.entries',
+  'es7.object.get-own-property-descriptors',
+  'es7.object.values',
+  'es7.string.pad-end',
+  'es7.string.pad-start',
+];
+
+/**
+ * @param {string} command
+ * @param {string[]} args
+ */
+function runCommand(command, args) {
+  execFileSync(command, args, {cwd: __dirname});
+}
+
+/**
+ * @param {number} version
+ */
+function installCoreJs(version) {
+  runCommand('yarn', [
+    'add',
+    `core-js@${version}`,
+  ]);
+}
+
+function removeCoreJs() {
+  try {
+    runCommand('yarn', [
+      'remove',
+      'core-js',
+    ]);
+  } catch (e) { }
+}
+
+/**
+ * @param {{group: string, name: string, code: string, babelrc?: *}} options
+ */
+async function createVariant(options) {
+  const {group, name, code, babelrc} = options;
+  const dir = `${VARIANT_DIR}/${group}/${name.replace(/[^a-zA-Z0-9]+/g, '-')}`;
+
+  if (!fs.existsSync(`${dir}/main.bundle.js`) && (STAGE === 'build' || STAGE === 'all')) {
+    fs.mkdirSync(dir, {recursive: true});
+    fs.writeFileSync(`${dir}/main.js`, code);
+    fs.writeFileSync(`${dir}/.babelrc`, JSON.stringify(babelrc || {}, null, 2));
+    // Not used in this script, but useful for running Lighthouse manually.
+    // Just need to start a web server first.
+    fs.writeFileSync(`${dir}/index.html`, `<title>${name}</title><script src=main.bundle.min.js>`);
+
+    // Note: No babelrc will make babel a glorified `cp`.
+    runCommand('yarn', [
+      'babel',
+      `${dir}/main.js`,
+      '--config-file', `${dir}/.babelrc`,
+      '--ignore', 'node_modules/**/*.js',
+      '-o', `${dir}/main.transpiled.js`,
+    ]);
+
+    // Transform any require statements (like for core-js) into a big bundle.
+    runCommand('yarn', [
+      'browserify',
+      `${dir}/main.transpiled.js`,
+      '-o', `${dir}/main.bundle.js`,
+    ]);
+
+    // Minify.
+    runCommand('yarn', [
+      'terser',
+      `${dir}/main.bundle.js`,
+      '-o', `${dir}/main.bundle.min.js`,
+    ]);
+  }
+
+  if (STAGE === 'audit' || STAGE === 'all') {
+    // Instead of running Lighthouse, use LegacyJavascript directly. Requires some setup.
+    // Much faster than running Lighthouse.
+    const documentUrl = 'http://localhost/index.html'; // These URLs don't matter.
+    const scriptUrl = 'https://localhost/main.bundle.min.js';
+    const networkRecords = [
+      {url: documentUrl},
+      {url: scriptUrl},
+    ];
+    const devtoolsLogs = networkRecordsToDevtoolsLog(networkRecords);
+    const jsRequestWillBeSentEvent = devtoolsLogs.find(e =>
+      e.method === 'Network.requestWillBeSent' && e.params.request.url === scriptUrl);
+    if (!jsRequestWillBeSentEvent) throw new Error('jsRequestWillBeSentEvent is undefined');
+    // @ts-ignore - the log event is not narrowed to 'Network.requestWillBeSent' event from find
+    const jsRequestId = jsRequestWillBeSentEvent.params.requestId;
+    const code = fs.readFileSync(`${dir}/main.bundle.min.js`, 'utf-8').toString();
+    /** @type {Pick<LH.Artifacts, 'devtoolsLogs'|'URL'|'ScriptElements'>} */
+    const artifacts = {
+      URL: {finalUrl: documentUrl, requestedUrl: documentUrl},
+      devtoolsLogs: {
+        [LegacyJavascript.DEFAULT_PASS]: devtoolsLogs,
+      },
+      ScriptElements: [
+        // @ts-ignore - partial ScriptElement excluding unused DOM properties
+        {requestId: jsRequestId, content: code},
+      ],
+    };
+    // @ts-ignore: partial Artifacts.
+    const legacyJavascriptResults = await LegacyJavascript.audit(artifacts, {
+      computedCache: new Map(),
+    });
+    fs.writeFileSync(`${dir}/legacy-javascript.json`,
+      JSON.stringify(legacyJavascriptResults.details.items, null, 2));
+  }
+}
+
+function makeSummary() {
+  let totalSignals = 0;
+  const variants = [];
+  for (const dir of glob.sync('*/*', {cwd: VARIANT_DIR})) {
+    /** @type {Array<{signals: string[]}>} */
+    const legacyJavascriptItems = require(`${VARIANT_DIR}/${dir}/legacy-javascript.json`);
+    const signals = legacyJavascriptItems.reduce((acc, cur) => {
+      totalSignals += cur.signals.length;
+      return acc.concat(cur.signals);
+    }, /** @type {string[]} */ ([])).join(', ');
+    variants.push({name: dir, signals});
+  }
+  return {
+    totalSignals,
+    variantsMissingSignals: variants.filter(v => !v.signals).map(v => v.name),
+    variants,
+  };
+}
+
+async function main() {
+  for (const plugin of plugins) {
+    await createVariant({
+      group: 'only-plugin',
+      name: plugin,
+      code: mainCode,
+      babelrc: {
+        plugins: [plugin],
+      },
+    });
+  }
+
+  for (const coreJsVersion of [2, 3]) {
+    removeCoreJs();
+    installCoreJs(coreJsVersion);
+
+    for (const esmodules of [true, false]) {
+      await createVariant({
+        group: `core-js-${coreJsVersion}-preset-env-esmodules`,
+        name: String(esmodules),
+        code: mainCode,
+        babelrc: {
+          presets: [
+            [
+              '@babel/preset-env',
+              {
+                targets: {esmodules},
+                useBuiltIns: 'entry',
+                corejs: coreJsVersion,
+              },
+            ],
+          ],
+        },
+      });
+    }
+
+    for (const polyfill of polyfills) {
+      await createVariant({
+        group: `core-js-${coreJsVersion}-only-polyfill`,
+        name: polyfill,
+        code: `require("core-js/modules/${polyfill}")`,
+      });
+    }
+  }
+
+  removeCoreJs();
+
+  const summary = makeSummary();
+  fs.writeFileSync(`${__dirname}/summary-signals.json`, JSON.stringify(summary, null, 2));
+  console.log({
+    totalSignals: summary.totalSignals,
+    variantsMissingSignals: summary.variantsMissingSignals,
+  });
+  console.table(summary.variants);
+
+  runCommand('sh', [
+    'update-sizes.sh',
+  ]);
+}
+
+main();
diff --git a/lighthouse-core/scripts/legacy-javascript/summary-signals.json b/lighthouse-core/scripts/legacy-javascript/summary-signals.json
new file mode 100644
index 000000000..e5967863f
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/summary-signals.json
@@ -0,0 +1,1025 @@
+{
+  "totalSignals": 205,
+  "variantsMissingSignals": [
+    "core-js-2-only-polyfill/es6-array-copy-within",
+    "core-js-2-only-polyfill/es6-array-every",
+    "core-js-2-only-polyfill/es6-array-filter",
+    "core-js-2-only-polyfill/es6-array-index-of",
+    "core-js-2-only-polyfill/es6-array-iterator",
+    "core-js-2-only-polyfill/es6-array-map",
+    "core-js-2-only-polyfill/es6-array-reduce",
+    "core-js-2-only-polyfill/es6-array-reduce-right",
+    "core-js-2-only-polyfill/es6-array-some",
+    "core-js-2-only-polyfill/es6-array-species",
+    "core-js-2-only-polyfill/es6-date-to-primitive",
+    "core-js-2-only-polyfill/es6-date-to-string",
+    "core-js-2-only-polyfill/es6-function-bind",
+    "core-js-2-only-polyfill/es6-function-has-instance",
+    "core-js-2-only-polyfill/es6-function-name",
+    "core-js-2-only-polyfill/es6-math-acosh",
+    "core-js-2-only-polyfill/es6-math-asinh",
+    "core-js-2-only-polyfill/es6-math-atanh",
+    "core-js-2-only-polyfill/es6-math-cbrt",
+    "core-js-2-only-polyfill/es6-math-clz32",
+    "core-js-2-only-polyfill/es6-math-cosh",
+    "core-js-2-only-polyfill/es6-math-expm1",
+    "core-js-2-only-polyfill/es6-math-fround",
+    "core-js-2-only-polyfill/es6-math-hypot",
+    "core-js-2-only-polyfill/es6-math-imul",
+    "core-js-2-only-polyfill/es6-math-log10",
+    "core-js-2-only-polyfill/es6-math-log1p",
+    "core-js-2-only-polyfill/es6-math-log2",
+    "core-js-2-only-polyfill/es6-math-sign",
+    "core-js-2-only-polyfill/es6-math-sinh",
+    "core-js-2-only-polyfill/es6-math-tanh",
+    "core-js-2-only-polyfill/es6-math-trunc",
+    "core-js-2-only-polyfill/es6-number-constructor",
+    "core-js-2-only-polyfill/es6-number-epsilon",
+    "core-js-2-only-polyfill/es6-number-max-safe-integer",
+    "core-js-2-only-polyfill/es6-number-min-safe-integer",
+    "core-js-2-only-polyfill/es6-object-freeze",
+    "core-js-2-only-polyfill/es6-object-get-own-property-descriptor",
+    "core-js-2-only-polyfill/es6-object-get-own-property-names",
+    "core-js-2-only-polyfill/es6-object-get-prototype-of",
+    "core-js-2-only-polyfill/es6-object-is-extensible",
+    "core-js-2-only-polyfill/es6-object-is-frozen",
+    "core-js-2-only-polyfill/es6-object-is-sealed",
+    "core-js-2-only-polyfill/es6-object-keys",
+    "core-js-2-only-polyfill/es6-object-prevent-extensions",
+    "core-js-2-only-polyfill/es6-object-seal",
+    "core-js-2-only-polyfill/es6-object-to-string",
+    "core-js-2-only-polyfill/es6-reflect-define-property",
+    "core-js-2-only-polyfill/es6-string-iterator",
+    "core-js-2-only-polyfill/es6-string-trim",
+    "core-js-2-preset-env-esmodules/true",
+    "core-js-3-only-polyfill/es6-array-copy-within",
+    "core-js-3-only-polyfill/es6-array-every",
+    "core-js-3-only-polyfill/es6-array-filter",
+    "core-js-3-only-polyfill/es6-array-index-of",
+    "core-js-3-only-polyfill/es6-array-iterator",
+    "core-js-3-only-polyfill/es6-array-map",
+    "core-js-3-only-polyfill/es6-array-reduce",
+    "core-js-3-only-polyfill/es6-array-reduce-right",
+    "core-js-3-only-polyfill/es6-array-some",
+    "core-js-3-only-polyfill/es6-array-species",
+    "core-js-3-only-polyfill/es6-date-to-primitive",
+    "core-js-3-only-polyfill/es6-date-to-string",
+    "core-js-3-only-polyfill/es6-function-bind",
+    "core-js-3-only-polyfill/es6-function-has-instance",
+    "core-js-3-only-polyfill/es6-function-name",
+    "core-js-3-only-polyfill/es6-math-acosh",
+    "core-js-3-only-polyfill/es6-math-asinh",
+    "core-js-3-only-polyfill/es6-math-atanh",
+    "core-js-3-only-polyfill/es6-math-cbrt",
+    "core-js-3-only-polyfill/es6-math-clz32",
+    "core-js-3-only-polyfill/es6-math-cosh",
+    "core-js-3-only-polyfill/es6-math-expm1",
+    "core-js-3-only-polyfill/es6-math-fround",
+    "core-js-3-only-polyfill/es6-math-hypot",
+    "core-js-3-only-polyfill/es6-math-imul",
+    "core-js-3-only-polyfill/es6-math-log10",
+    "core-js-3-only-polyfill/es6-math-log1p",
+    "core-js-3-only-polyfill/es6-math-log2",
+    "core-js-3-only-polyfill/es6-math-sign",
+    "core-js-3-only-polyfill/es6-math-sinh",
+    "core-js-3-only-polyfill/es6-math-tanh",
+    "core-js-3-only-polyfill/es6-math-trunc",
+    "core-js-3-only-polyfill/es6-number-constructor",
+    "core-js-3-only-polyfill/es6-number-epsilon",
+    "core-js-3-only-polyfill/es6-number-max-safe-integer",
+    "core-js-3-only-polyfill/es6-number-min-safe-integer",
+    "core-js-3-only-polyfill/es6-object-freeze",
+    "core-js-3-only-polyfill/es6-object-get-own-property-descriptor",
+    "core-js-3-only-polyfill/es6-object-get-own-property-names",
+    "core-js-3-only-polyfill/es6-object-get-prototype-of",
+    "core-js-3-only-polyfill/es6-object-is-extensible",
+    "core-js-3-only-polyfill/es6-object-is-frozen",
+    "core-js-3-only-polyfill/es6-object-is-sealed",
+    "core-js-3-only-polyfill/es6-object-keys",
+    "core-js-3-only-polyfill/es6-object-prevent-extensions",
+    "core-js-3-only-polyfill/es6-object-seal",
+    "core-js-3-only-polyfill/es6-object-to-string",
+    "core-js-3-only-polyfill/es6-reflect-define-property",
+    "core-js-3-only-polyfill/es6-string-iterator",
+    "core-js-3-only-polyfill/es6-string-trim",
+    "core-js-3-preset-env-esmodules/true"
+  ],
+  "variants": [
+    {
+      "name": "core-js-2-only-polyfill/es6-array-copy-within",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-every",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-fill",
+      "signals": "Array.fill"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-filter",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-find",
+      "signals": "Array.prototype.find"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-find-index",
+      "signals": "Array.prototype.findIndex"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-for-each",
+      "signals": "Array.prototype.forEach"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-from",
+      "signals": "Array.from"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-index-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-is-array",
+      "signals": "Array.isArray"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-last-index-of",
+      "signals": "Array.prototype.lastIndexOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-map",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-of",
+      "signals": "Array.of"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-reduce",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-reduce-right",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-some",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-species",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-now",
+      "signals": "Date.now"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-iso-string",
+      "signals": "Date.prototype.toISOString"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-json",
+      "signals": "Date.prototype.toJSON"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-primitive",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-bind",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-has-instance",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-name",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-map",
+      "signals": "Map"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-acosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-asinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-atanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-cbrt",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-clz32",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-cosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-expm1",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-fround",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-hypot",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-imul",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log10",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log1p",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log2",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-sign",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-sinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-tanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-trunc",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-constructor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-epsilon",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-is-integer",
+      "signals": "Number.isInteger"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-is-safe-integer",
+      "signals": "Number.isSafeInteger"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-max-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-min-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-parse-float",
+      "signals": "Number.parseFloat"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-parse-int",
+      "signals": "Number.parseInt"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-assign",
+      "signals": "Object.assign"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-create",
+      "signals": "Object.create"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-define-properties",
+      "signals": "Object.defineProperties"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-define-property",
+      "signals": "Object.defineProperty"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-freeze",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-own-property-descriptor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-own-property-names",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-prototype-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-extensible",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-frozen",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-sealed",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-keys",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-prevent-extensions",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-seal",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-set-prototype-of",
+      "signals": "Object.setPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-promise",
+      "signals": "Promise"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-apply",
+      "signals": "Reflect.apply"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-construct",
+      "signals": "Reflect.construct"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-define-property",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-delete-property",
+      "signals": "Reflect.deleteProperty"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get",
+      "signals": "Reflect.get"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get-own-property-descriptor",
+      "signals": "Reflect.getOwnPropertyDescriptor"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get-prototype-of",
+      "signals": "Reflect.getPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-has",
+      "signals": "Reflect.has"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-is-extensible",
+      "signals": "Reflect.isExtensible"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-own-keys",
+      "signals": "Reflect.ownKeys"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-prevent-extensions",
+      "signals": "Reflect.preventExtensions"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-set",
+      "signals": "Reflect.set"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-set-prototype-of",
+      "signals": "Reflect.setPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-set",
+      "signals": "Set"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-code-point-at",
+      "signals": "String.prototype.codePointAt"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-ends-with",
+      "signals": "String.prototype.endsWith"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-from-code-point",
+      "signals": "String.fromCodePoint"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-includes",
+      "signals": "String.prototype.includes"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-raw",
+      "signals": "String.raw"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-repeat",
+      "signals": "String.prototype.repeat"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-starts-with",
+      "signals": "String.prototype.startsWith"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-trim",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-array-buffer",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-data-view",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-float32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-float64-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float64Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int16Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int8Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint16Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint8-clamped-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint8ClampedArray"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-weak-map",
+      "signals": "WeakMap"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-weak-set",
+      "signals": "WeakSet"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-array-includes",
+      "signals": "Array.prototype.includes"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-entries",
+      "signals": "Object.entries"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-get-own-property-descriptors",
+      "signals": "Object.getOwnPropertyDescriptors"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-values",
+      "signals": "Object.values"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-string-pad-end",
+      "signals": "String.prototype.padEnd"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-string-pad-start",
+      "signals": "String.prototype.padStart"
+    },
+    {
+      "name": "core-js-2-preset-env-esmodules/false",
+      "signals": "@babel/plugin-transform-classes, @babel/plugin-transform-regenerator, @babel/plugin-transform-spread"
+    },
+    {
+      "name": "core-js-2-preset-env-esmodules/true",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-copy-within",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-every",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-fill",
+      "signals": "Array.fill"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-filter",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-find",
+      "signals": "Array.prototype.find"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-find-index",
+      "signals": "Array.prototype.findIndex"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-for-each",
+      "signals": "Array.prototype.forEach"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-from",
+      "signals": "Array.from"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-index-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-is-array",
+      "signals": "Array.isArray"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-last-index-of",
+      "signals": "Array.prototype.lastIndexOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-map",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-of",
+      "signals": "Array.of"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-reduce",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-reduce-right",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-some",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-species",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-now",
+      "signals": "Date.now"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-iso-string",
+      "signals": "Date.prototype.toISOString"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-json",
+      "signals": "Date.prototype.toJSON"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-primitive",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-bind",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-has-instance",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-name",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-map",
+      "signals": "Map"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-acosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-asinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-atanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-cbrt",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-clz32",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-cosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-expm1",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-fround",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-hypot",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-imul",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log10",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log1p",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log2",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-sign",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-sinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-tanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-trunc",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-constructor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-epsilon",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-is-integer",
+      "signals": "Number.isInteger"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-is-safe-integer",
+      "signals": "Number.isSafeInteger"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-max-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-min-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-parse-float",
+      "signals": "Number.parseFloat"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-parse-int",
+      "signals": "Number.parseInt"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-assign",
+      "signals": "Object.assign"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-create",
+      "signals": "Object.create"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-define-properties",
+      "signals": "Object.defineProperties"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-define-property",
+      "signals": "Object.defineProperty"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-freeze",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-own-property-descriptor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-own-property-names",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-prototype-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-extensible",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-frozen",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-sealed",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-keys",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-prevent-extensions",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-seal",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-set-prototype-of",
+      "signals": "Object.setPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-promise",
+      "signals": "Promise"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-apply",
+      "signals": "Reflect.apply"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-construct",
+      "signals": "Reflect.construct"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-define-property",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-delete-property",
+      "signals": "Reflect.deleteProperty"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get",
+      "signals": "Reflect.get"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get-own-property-descriptor",
+      "signals": "Reflect.getOwnPropertyDescriptor"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get-prototype-of",
+      "signals": "Reflect.getPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-has",
+      "signals": "Reflect.has"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-is-extensible",
+      "signals": "Reflect.isExtensible"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-own-keys",
+      "signals": "Reflect.ownKeys"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-prevent-extensions",
+      "signals": "Reflect.preventExtensions"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-set",
+      "signals": "Reflect.set"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-set-prototype-of",
+      "signals": "Reflect.setPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-set",
+      "signals": "Set"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-code-point-at",
+      "signals": "String.prototype.codePointAt"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-ends-with",
+      "signals": "String.prototype.endsWith"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-from-code-point",
+      "signals": "String.fromCodePoint"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-includes",
+      "signals": "String.prototype.includes"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-raw",
+      "signals": "String.raw"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-repeat",
+      "signals": "String.prototype.repeat"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-starts-with",
+      "signals": "String.prototype.startsWith"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-trim",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-array-buffer",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-data-view",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-float32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-float64-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float64Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int16Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int8Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint16Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint8-clamped-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint8ClampedArray"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-weak-map",
+      "signals": "WeakMap"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-weak-set",
+      "signals": "WeakSet"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-array-includes",
+      "signals": "Array.prototype.includes"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-entries",
+      "signals": "Object.entries"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-get-own-property-descriptors",
+      "signals": "Object.getOwnPropertyDescriptors"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-values",
+      "signals": "Object.values"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-string-pad-end",
+      "signals": "String.prototype.padEnd"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-string-pad-start",
+      "signals": "String.prototype.padStart"
+    },
+    {
+      "name": "core-js-3-preset-env-esmodules/false",
+      "signals": "@babel/plugin-transform-classes, @babel/plugin-transform-regenerator, @babel/plugin-transform-spread"
+    },
+    {
+      "name": "core-js-3-preset-env-esmodules/true",
+      "signals": ""
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-classes",
+      "signals": "@babel/plugin-transform-classes"
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-regenerator",
+      "signals": "@babel/plugin-transform-regenerator"
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-spread",
+      "signals": "@babel/plugin-transform-spread"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt b/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt
new file mode 100644
index 000000000..bea8fbce2
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt
@@ -0,0 +1,244 @@
+variants/core-js-2-only-polyfill
+1482778 total
+  47738 variants/core-js-2-only-polyfill/es6-typed-uint8-clamped-array/main.bundle.min.js
+  47718 variants/core-js-2-only-polyfill/es6-typed-float64-array/main.bundle.min.js
+  47718 variants/core-js-2-only-polyfill/es6-typed-float32-array/main.bundle.min.js
+  47714 variants/core-js-2-only-polyfill/es6-typed-uint32-array/main.bundle.min.js
+  47714 variants/core-js-2-only-polyfill/es6-typed-uint16-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-uint8-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-int32-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-int16-array/main.bundle.min.js
+  47706 variants/core-js-2-only-polyfill/es6-typed-int8-array/main.bundle.min.js
+  29514 variants/core-js-2-only-polyfill/es6-map/main.bundle.min.js
+  29420 variants/core-js-2-only-polyfill/es6-set/main.bundle.min.js
+  27277 variants/core-js-2-only-polyfill/es6-weak-map/main.bundle.min.js
+  24645 variants/core-js-2-only-polyfill/es6-promise/main.bundle.min.js
+  23127 variants/core-js-2-only-polyfill/es6-typed-array-buffer/main.bundle.min.js
+  22881 variants/core-js-2-only-polyfill/es6-weak-set/main.bundle.min.js
+  20885 variants/core-js-2-only-polyfill/es6-typed-data-view/main.bundle.min.js
+  17640 variants/core-js-2-only-polyfill/es6-array-iterator/main.bundle.min.js
+  17387 variants/core-js-2-only-polyfill/es6-number-constructor/main.bundle.min.js
+  17329 variants/core-js-2-only-polyfill/es6-string-iterator/main.bundle.min.js
+  14880 variants/core-js-2-only-polyfill/es6-reflect-construct/main.bundle.min.js
+  12738 variants/core-js-2-only-polyfill/es7-object-get-own-property-descriptors/main.bundle.min.js
+  12329 variants/core-js-2-only-polyfill/es6-object-create/main.bundle.min.js
+  12184 variants/core-js-2-only-polyfill/es6-array-from/main.bundle.min.js
+  11963 variants/core-js-2-only-polyfill/es6-object-assign/main.bundle.min.js
+  11575 variants/core-js-2-only-polyfill/es6-object-get-own-property-names/main.bundle.min.js
+  11412 variants/core-js-2-only-polyfill/es6-array-find-index/main.bundle.min.js
+  11385 variants/core-js-2-only-polyfill/es6-array-find/main.bundle.min.js
+  11295 variants/core-js-2-only-polyfill/es7-object-entries/main.bundle.min.js
+  11290 variants/core-js-2-only-polyfill/es7-object-values/main.bundle.min.js
+  11240 variants/core-js-2-only-polyfill/es6-array-for-each/main.bundle.min.js
+  11213 variants/core-js-2-only-polyfill/es6-array-filter/main.bundle.min.js
+  11206 variants/core-js-2-only-polyfill/es6-array-every/main.bundle.min.js
+  11199 variants/core-js-2-only-polyfill/es6-array-some/main.bundle.min.js
+  11192 variants/core-js-2-only-polyfill/es6-array-map/main.bundle.min.js
+  11187 variants/core-js-2-only-polyfill/es6-object-keys/main.bundle.min.js
+  11159 variants/core-js-2-only-polyfill/es6-reflect-own-keys/main.bundle.min.js
+  11146 variants/core-js-2-only-polyfill/es6-object-define-properties/main.bundle.min.js
+  10840 variants/core-js-2-only-polyfill/es6-reflect-set/main.bundle.min.js
+  10362 variants/core-js-2-only-polyfill/es6-reflect-get/main.bundle.min.js
+  10323 variants/core-js-2-only-polyfill/es7-array-includes/main.bundle.min.js
+   9992 variants/core-js-2-only-polyfill/es6-array-index-of/main.bundle.min.js
+   9974 variants/core-js-2-only-polyfill/es6-string-ends-with/main.bundle.min.js
+   9928 variants/core-js-2-only-polyfill/es6-string-starts-with/main.bundle.min.js
+   9916 variants/core-js-2-only-polyfill/es6-array-copy-within/main.bundle.min.js
+   9840 variants/core-js-2-only-polyfill/es6-reflect-set-prototype-of/main.bundle.min.js
+   9726 variants/core-js-2-only-polyfill/es6-array-fill/main.bundle.min.js
+   9706 variants/core-js-2-only-polyfill/es6-array-reduce-right/main.bundle.min.js
+   9682 variants/core-js-2-only-polyfill/es6-object-set-prototype-of/main.bundle.min.js
+   9680 variants/core-js-2-only-polyfill/es6-array-reduce/main.bundle.min.js
+   9561 variants/core-js-2-only-polyfill/es7-string-pad-start/main.bundle.min.js
+   9554 variants/core-js-2-only-polyfill/es7-string-pad-end/main.bundle.min.js
+   9532 variants/core-js-2-only-polyfill/es6-object-get-own-property-descriptor/main.bundle.min.js
+   9458 variants/core-js-2-only-polyfill/es6-array-last-index-of/main.bundle.min.js
+   9315 variants/core-js-2-only-polyfill/es6-string-includes/main.bundle.min.js
+   9196 variants/core-js-2-only-polyfill/es6-reflect-delete-property/main.bundle.min.js
+   9173 variants/core-js-2-only-polyfill/es6-reflect-get-own-property-descriptor/main.bundle.min.js
+   9120 variants/core-js-2-only-polyfill/es6-object-prevent-extensions/main.bundle.min.js
+   9093 variants/core-js-2-only-polyfill/es6-number-parse-int/main.bundle.min.js
+   9087 variants/core-js-2-only-polyfill/es6-number-parse-float/main.bundle.min.js
+   9041 variants/core-js-2-only-polyfill/es6-object-freeze/main.bundle.min.js
+   9027 variants/core-js-2-only-polyfill/es6-object-seal/main.bundle.min.js
+   9009 variants/core-js-2-only-polyfill/es6-object-get-prototype-of/main.bundle.min.js
+   8843 variants/core-js-2-only-polyfill/es6-string-raw/main.bundle.min.js
+   8792 variants/core-js-2-only-polyfill/es6-function-bind/main.bundle.min.js
+   8667 variants/core-js-2-only-polyfill/es6-reflect-get-prototype-of/main.bundle.min.js
+   8593 variants/core-js-2-only-polyfill/es6-string-trim/main.bundle.min.js
+   8445 variants/core-js-2-only-polyfill/es6-string-from-code-point/main.bundle.min.js
+   8423 variants/core-js-2-only-polyfill/es6-date-to-iso-string/main.bundle.min.js
+   8413 variants/core-js-2-only-polyfill/es6-string-code-point-at/main.bundle.min.js
+   8219 variants/core-js-2-only-polyfill/es6-math-fround/main.bundle.min.js
+   8217 variants/core-js-2-only-polyfill/es6-string-repeat/main.bundle.min.js
+   8193 variants/core-js-2-only-polyfill/es6-array-of/main.bundle.min.js
+   8191 variants/core-js-2-only-polyfill/es6-date-to-json/main.bundle.min.js
+   8004 variants/core-js-2-only-polyfill/es6-reflect-define-property/main.bundle.min.js
+   7998 variants/core-js-2-only-polyfill/es6-object-is-extensible/main.bundle.min.js
+   7984 variants/core-js-2-only-polyfill/es6-math-sinh/main.bundle.min.js
+   7970 variants/core-js-2-only-polyfill/es6-object-is-sealed/main.bundle.min.js
+   7970 variants/core-js-2-only-polyfill/es6-object-is-frozen/main.bundle.min.js
+   7942 variants/core-js-2-only-polyfill/es6-reflect-apply/main.bundle.min.js
+   7907 variants/core-js-2-only-polyfill/es6-math-tanh/main.bundle.min.js
+   7906 variants/core-js-2-only-polyfill/es6-math-acosh/main.bundle.min.js
+   7876 variants/core-js-2-only-polyfill/es6-number-is-safe-integer/main.bundle.min.js
+   7821 variants/core-js-2-only-polyfill/es6-math-expm1/main.bundle.min.js
+   7814 variants/core-js-2-only-polyfill/es6-array-is-array/main.bundle.min.js
+   7786 variants/core-js-2-only-polyfill/es6-math-hypot/main.bundle.min.js
+   7763 variants/core-js-2-only-polyfill/es6-reflect-prevent-extensions/main.bundle.min.js
+   7752 variants/core-js-2-only-polyfill/es6-math-imul/main.bundle.min.js
+   7743 variants/core-js-2-only-polyfill/es6-number-is-integer/main.bundle.min.js
+   7702 variants/core-js-2-only-polyfill/es6-math-cbrt/main.bundle.min.js
+   7693 variants/core-js-2-only-polyfill/es6-reflect-is-extensible/main.bundle.min.js
+   7654 variants/core-js-2-only-polyfill/es6-math-log1p/main.bundle.min.js
+   7631 variants/core-js-2-only-polyfill/es6-math-sign/main.bundle.min.js
+   7628 variants/core-js-2-only-polyfill/es6-math-asinh/main.bundle.min.js
+   7608 variants/core-js-2-only-polyfill/es6-object-define-property/main.bundle.min.js
+   7592 variants/core-js-2-only-polyfill/es6-math-atanh/main.bundle.min.js
+   7551 variants/core-js-2-only-polyfill/es6-math-clz32/main.bundle.min.js
+   7539 variants/core-js-2-only-polyfill/es6-reflect-has/main.bundle.min.js
+   7533 variants/core-js-2-only-polyfill/es6-math-cosh/main.bundle.min.js
+   7531 variants/core-js-2-only-polyfill/es6-math-trunc/main.bundle.min.js
+   7530 variants/core-js-2-only-polyfill/es6-number-min-safe-integer/main.bundle.min.js
+   7529 variants/core-js-2-only-polyfill/es6-number-max-safe-integer/main.bundle.min.js
+   7523 variants/core-js-2-only-polyfill/es6-math-log10/main.bundle.min.js
+   7516 variants/core-js-2-only-polyfill/es6-math-log2/main.bundle.min.js
+   7506 variants/core-js-2-only-polyfill/es6-date-now/main.bundle.min.js
+   7501 variants/core-js-2-only-polyfill/es6-number-epsilon/main.bundle.min.js
+   6915 variants/core-js-2-only-polyfill/es6-object-to-string/main.bundle.min.js
+   5994 variants/core-js-2-only-polyfill/es6-function-has-instance/main.bundle.min.js
+   5993 variants/core-js-2-only-polyfill/es6-date-to-string/main.bundle.min.js
+   5422 variants/core-js-2-only-polyfill/es6-date-to-primitive/main.bundle.min.js
+   4774 variants/core-js-2-only-polyfill/es6-array-species/main.bundle.min.js
+   3410 variants/core-js-2-only-polyfill/es6-function-name/main.bundle.min.js
+
+variants/core-js-2-preset-env-esmodules
+3567 total
+2259 variants/core-js-2-preset-env-esmodules/false/main.bundle.min.js
+1308 variants/core-js-2-preset-env-esmodules/true/main.bundle.min.js
+
+variants/core-js-3-only-polyfill
+1482721 total
+  47737 variants/core-js-3-only-polyfill/es6-typed-uint8-clamped-array/main.bundle.min.js
+  47717 variants/core-js-3-only-polyfill/es6-typed-float64-array/main.bundle.min.js
+  47717 variants/core-js-3-only-polyfill/es6-typed-float32-array/main.bundle.min.js
+  47713 variants/core-js-3-only-polyfill/es6-typed-uint32-array/main.bundle.min.js
+  47713 variants/core-js-3-only-polyfill/es6-typed-uint16-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-uint8-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-int32-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-int16-array/main.bundle.min.js
+  47705 variants/core-js-3-only-polyfill/es6-typed-int8-array/main.bundle.min.js
+  29516 variants/core-js-3-only-polyfill/es6-map/main.bundle.min.js
+  29422 variants/core-js-3-only-polyfill/es6-set/main.bundle.min.js
+  27278 variants/core-js-3-only-polyfill/es6-weak-map/main.bundle.min.js
+  24645 variants/core-js-3-only-polyfill/es6-promise/main.bundle.min.js
+  23127 variants/core-js-3-only-polyfill/es6-typed-array-buffer/main.bundle.min.js
+  22880 variants/core-js-3-only-polyfill/es6-weak-set/main.bundle.min.js
+  20885 variants/core-js-3-only-polyfill/es6-typed-data-view/main.bundle.min.js
+  17642 variants/core-js-3-only-polyfill/es6-array-iterator/main.bundle.min.js
+  17387 variants/core-js-3-only-polyfill/es6-number-constructor/main.bundle.min.js
+  17329 variants/core-js-3-only-polyfill/es6-string-iterator/main.bundle.min.js
+  14882 variants/core-js-3-only-polyfill/es6-reflect-construct/main.bundle.min.js
+  12740 variants/core-js-3-only-polyfill/es7-object-get-own-property-descriptors/main.bundle.min.js
+  12329 variants/core-js-3-only-polyfill/es6-object-create/main.bundle.min.js
+  12185 variants/core-js-3-only-polyfill/es6-array-from/main.bundle.min.js
+  11962 variants/core-js-3-only-polyfill/es6-object-assign/main.bundle.min.js
+  11574 variants/core-js-3-only-polyfill/es6-object-get-own-property-names/main.bundle.min.js
+  11412 variants/core-js-3-only-polyfill/es6-array-find-index/main.bundle.min.js
+  11385 variants/core-js-3-only-polyfill/es6-array-find/main.bundle.min.js
+  11294 variants/core-js-3-only-polyfill/es7-object-entries/main.bundle.min.js
+  11289 variants/core-js-3-only-polyfill/es7-object-values/main.bundle.min.js
+  11239 variants/core-js-3-only-polyfill/es6-array-for-each/main.bundle.min.js
+  11212 variants/core-js-3-only-polyfill/es6-array-filter/main.bundle.min.js
+  11205 variants/core-js-3-only-polyfill/es6-array-every/main.bundle.min.js
+  11198 variants/core-js-3-only-polyfill/es6-array-some/main.bundle.min.js
+  11191 variants/core-js-3-only-polyfill/es6-array-map/main.bundle.min.js
+  11186 variants/core-js-3-only-polyfill/es6-object-keys/main.bundle.min.js
+  11158 variants/core-js-3-only-polyfill/es6-reflect-own-keys/main.bundle.min.js
+  11145 variants/core-js-3-only-polyfill/es6-object-define-properties/main.bundle.min.js
+  10839 variants/core-js-3-only-polyfill/es6-reflect-set/main.bundle.min.js
+  10361 variants/core-js-3-only-polyfill/es6-reflect-get/main.bundle.min.js
+  10324 variants/core-js-3-only-polyfill/es7-array-includes/main.bundle.min.js
+   9991 variants/core-js-3-only-polyfill/es6-array-index-of/main.bundle.min.js
+   9973 variants/core-js-3-only-polyfill/es6-string-ends-with/main.bundle.min.js
+   9927 variants/core-js-3-only-polyfill/es6-string-starts-with/main.bundle.min.js
+   9915 variants/core-js-3-only-polyfill/es6-array-copy-within/main.bundle.min.js
+   9839 variants/core-js-3-only-polyfill/es6-reflect-set-prototype-of/main.bundle.min.js
+   9725 variants/core-js-3-only-polyfill/es6-array-fill/main.bundle.min.js
+   9705 variants/core-js-3-only-polyfill/es6-array-reduce-right/main.bundle.min.js
+   9681 variants/core-js-3-only-polyfill/es6-object-set-prototype-of/main.bundle.min.js
+   9679 variants/core-js-3-only-polyfill/es6-array-reduce/main.bundle.min.js
+   9561 variants/core-js-3-only-polyfill/es7-string-pad-start/main.bundle.min.js
+   9554 variants/core-js-3-only-polyfill/es7-string-pad-end/main.bundle.min.js
+   9531 variants/core-js-3-only-polyfill/es6-object-get-own-property-descriptor/main.bundle.min.js
+   9457 variants/core-js-3-only-polyfill/es6-array-last-index-of/main.bundle.min.js
+   9314 variants/core-js-3-only-polyfill/es6-string-includes/main.bundle.min.js
+   9195 variants/core-js-3-only-polyfill/es6-reflect-delete-property/main.bundle.min.js
+   9172 variants/core-js-3-only-polyfill/es6-reflect-get-own-property-descriptor/main.bundle.min.js
+   9119 variants/core-js-3-only-polyfill/es6-object-prevent-extensions/main.bundle.min.js
+   9094 variants/core-js-3-only-polyfill/es6-number-parse-int/main.bundle.min.js
+   9088 variants/core-js-3-only-polyfill/es6-number-parse-float/main.bundle.min.js
+   9040 variants/core-js-3-only-polyfill/es6-object-freeze/main.bundle.min.js
+   9026 variants/core-js-3-only-polyfill/es6-object-seal/main.bundle.min.js
+   9010 variants/core-js-3-only-polyfill/es6-object-get-prototype-of/main.bundle.min.js
+   8842 variants/core-js-3-only-polyfill/es6-string-raw/main.bundle.min.js
+   8792 variants/core-js-3-only-polyfill/es6-function-bind/main.bundle.min.js
+   8667 variants/core-js-3-only-polyfill/es6-reflect-get-prototype-of/main.bundle.min.js
+   8594 variants/core-js-3-only-polyfill/es6-string-trim/main.bundle.min.js
+   8444 variants/core-js-3-only-polyfill/es6-string-from-code-point/main.bundle.min.js
+   8424 variants/core-js-3-only-polyfill/es6-date-to-iso-string/main.bundle.min.js
+   8413 variants/core-js-3-only-polyfill/es6-string-code-point-at/main.bundle.min.js
+   8218 variants/core-js-3-only-polyfill/es6-math-fround/main.bundle.min.js
+   8217 variants/core-js-3-only-polyfill/es6-string-repeat/main.bundle.min.js
+   8194 variants/core-js-3-only-polyfill/es6-array-of/main.bundle.min.js
+   8192 variants/core-js-3-only-polyfill/es6-date-to-json/main.bundle.min.js
+   8003 variants/core-js-3-only-polyfill/es6-reflect-define-property/main.bundle.min.js
+   7997 variants/core-js-3-only-polyfill/es6-object-is-extensible/main.bundle.min.js
+   7983 variants/core-js-3-only-polyfill/es6-math-sinh/main.bundle.min.js
+   7969 variants/core-js-3-only-polyfill/es6-object-is-sealed/main.bundle.min.js
+   7969 variants/core-js-3-only-polyfill/es6-object-is-frozen/main.bundle.min.js
+   7941 variants/core-js-3-only-polyfill/es6-reflect-apply/main.bundle.min.js
+   7906 variants/core-js-3-only-polyfill/es6-math-tanh/main.bundle.min.js
+   7905 variants/core-js-3-only-polyfill/es6-math-acosh/main.bundle.min.js
+   7875 variants/core-js-3-only-polyfill/es6-number-is-safe-integer/main.bundle.min.js
+   7820 variants/core-js-3-only-polyfill/es6-math-expm1/main.bundle.min.js
+   7814 variants/core-js-3-only-polyfill/es6-array-is-array/main.bundle.min.js
+   7785 variants/core-js-3-only-polyfill/es6-math-hypot/main.bundle.min.js
+   7762 variants/core-js-3-only-polyfill/es6-reflect-prevent-extensions/main.bundle.min.js
+   7751 variants/core-js-3-only-polyfill/es6-math-imul/main.bundle.min.js
+   7742 variants/core-js-3-only-polyfill/es6-number-is-integer/main.bundle.min.js
+   7701 variants/core-js-3-only-polyfill/es6-math-cbrt/main.bundle.min.js
+   7692 variants/core-js-3-only-polyfill/es6-reflect-is-extensible/main.bundle.min.js
+   7653 variants/core-js-3-only-polyfill/es6-math-log1p/main.bundle.min.js
+   7630 variants/core-js-3-only-polyfill/es6-math-sign/main.bundle.min.js
+   7627 variants/core-js-3-only-polyfill/es6-math-asinh/main.bundle.min.js
+   7607 variants/core-js-3-only-polyfill/es6-object-define-property/main.bundle.min.js
+   7591 variants/core-js-3-only-polyfill/es6-math-atanh/main.bundle.min.js
+   7550 variants/core-js-3-only-polyfill/es6-math-clz32/main.bundle.min.js
+   7538 variants/core-js-3-only-polyfill/es6-reflect-has/main.bundle.min.js
+   7532 variants/core-js-3-only-polyfill/es6-math-cosh/main.bundle.min.js
+   7530 variants/core-js-3-only-polyfill/es6-math-trunc/main.bundle.min.js
+   7529 variants/core-js-3-only-polyfill/es6-number-min-safe-integer/main.bundle.min.js
+   7528 variants/core-js-3-only-polyfill/es6-number-max-safe-integer/main.bundle.min.js
+   7522 variants/core-js-3-only-polyfill/es6-math-log10/main.bundle.min.js
+   7515 variants/core-js-3-only-polyfill/es6-math-log2/main.bundle.min.js
+   7505 variants/core-js-3-only-polyfill/es6-date-now/main.bundle.min.js
+   7500 variants/core-js-3-only-polyfill/es6-number-epsilon/main.bundle.min.js
+   6917 variants/core-js-3-only-polyfill/es6-object-to-string/main.bundle.min.js
+   5993 variants/core-js-3-only-polyfill/es6-function-has-instance/main.bundle.min.js
+   5992 variants/core-js-3-only-polyfill/es6-date-to-string/main.bundle.min.js
+   5421 variants/core-js-3-only-polyfill/es6-date-to-primitive/main.bundle.min.js
+   4773 variants/core-js-3-only-polyfill/es6-array-species/main.bundle.min.js
+   3410 variants/core-js-3-only-polyfill/es6-function-name/main.bundle.min.js
+
+variants/core-js-3-preset-env-esmodules
+3567 total
+2259 variants/core-js-3-preset-env-esmodules/false/main.bundle.min.js
+1308 variants/core-js-3-preset-env-esmodules/true/main.bundle.min.js
+
+variants/only-plugin
+2770 total
+1152 variants/only-plugin/-babel-plugin-transform-spread/main.bundle.min.js
+ 814 variants/only-plugin/-babel-plugin-transform-classes/main.bundle.min.js
+ 804 variants/only-plugin/-babel-plugin-transform-regenerator/main.bundle.min.js
+
diff --git a/lighthouse-core/scripts/legacy-javascript/update-sizes.sh b/lighthouse-core/scripts/legacy-javascript/update-sizes.sh
new file mode 100644
index 000000000..e6b81c0fd
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/update-sizes.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+for d in variants/* ; do
+  echo "$d"
+  wc -c "$d"/*/main.bundle.min.js | sort -hr
+  printf "\n"
+done > summary-sizes.txt
diff --git a/lighthouse-core/scripts/release/prepare-package.sh b/lighthouse-core/scripts/release/prepare-package.sh
index ac175968e..2e3ffe3b8 100755
--- a/lighthouse-core/scripts/release/prepare-package.sh
+++ b/lighthouse-core/scripts/release/prepare-package.sh
@@ -28,9 +28,6 @@ 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
diff --git a/lighthouse-core/scripts/update-report-fixtures.js b/lighthouse-core/scripts/update-report-fixtures.js
index a5ed4c93a..02be1710b 100644
--- a/lighthouse-core/scripts/update-report-fixtures.js
+++ b/lighthouse-core/scripts/update-report-fixtures.js
@@ -13,20 +13,14 @@ const artifactPath = 'lighthouse-core/test/results/artifacts';
 const {server} = require('../../lighthouse-cli/test/fixtures/static-server.js');
 const budgetedConfig = require('../test/results/sample-config.js');
 
-/** @typedef {import('net').AddressInfo} AddressInfo */
-
 /**
  * Update the report artifacts. If artifactName is set only that artifact will be updated.
  * @param {keyof LH.Artifacts=} artifactName
  */
 async function update(artifactName) {
   // get an available port
-  server.listen(0, 'localhost');
-  const port = await new Promise(res => server.on('listening', () => {
-    // Not a pipe or a domain socket, so will not be a string. See https://nodejs.org/api/net.html#net_server_address.
-    const address = /** @type {AddressInfo} */ (server.address());
-    res(address.port);
-  }));
+  await server.listen(0, 'localhost');
+  const port = server.getPort();
 
   const oldArtifacts = assetSaver.loadArtifacts(artifactPath);
 
@@ -37,7 +31,7 @@ async function update(artifactName) {
   ].join(' ');
   const flags = cliFlags.getFlags(rawFlags);
   await cli.runLighthouse(url, flags, budgetedConfig);
-  await new Promise(res => server.close(res));
+  await server.close();
 
   if (artifactName) {
     // Revert everything except the one artifact
diff --git a/package.json b/package.json
index 1b36de5c5..d0768e65b 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,14 @@
   },
   "scripts": {
     "build-all": "npm-run-posix-or-windows build-all:task",
-    "build-all:task": "(yarn build-cdt-lib & yarn build-extension & yarn build-devtools & yarn build-lr & yarn build-viewer & wait) && yarn build-pack",
-    "build-all:task:windows": "yarn build-cdt-lib && yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer",
+    "build-all:task": "(yarn build-cdt-lib & yarn build-extension & yarn build-devtools & yarn build-lr & yarn build-viewer & yarn build-smokehouse-bundle & wait) && yarn build-pack",
+    "build-all:task:windows": "yarn build-cdt-lib && yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer && yarn build-smokehouse-bundle",
     "build-cdt-lib": "node ./build/build-cdt-lib.js",
-    "build-extension": "node ./build/build-extension.js",
+    "build-extension": "yarn build-extension-chrome && yarn build-extension-firefox",
+    "build-extension-chrome": "node ./build/build-extension.js chrome",
+    "build-extension-firefox": "node ./build/build-extension.js firefox",
     "build-devtools": "yarn link && yarn link lighthouse && node ./build/build-bundle.js clients/devtools-entry.js dist/lighthouse-dt-bundle.js && node ./build/build-dt-report-resources.js",
+    "build-smokehouse-bundle": "node ./build/build-smokehouse-bundle.js",
     "build-lr": "node ./build/build-lightrider-bundles.js",
     "build-pack": "bash build/build-pack.sh",
     "build-viewer": "node ./build/build-viewer.js",
@@ -30,6 +33,7 @@
     "test-clients": "jest \"clients/\"",
     "test-viewer": "yarn unit-viewer && jest lighthouse-viewer/test/viewer-test-pptr.js",
     "test-lantern": "bash lighthouse-core/scripts/test-lantern.sh",
+    "test-legacy-javascript": "bash lighthouse-core/scripts/test-legacy-javascript.sh",
     "test-docs": "yarn --cwd docs/recipes/auth && jest docs/recipes/integration-test && yarn --cwd docs/recipes/custom-gatherer-puppeteer test",
     "unit-core": "jest \"lighthouse-core/\"",
     "unit-cli": "jest \"lighthouse-cli/\"",
@@ -179,7 +183,7 @@
   },
   "bundlesize": [
     {
-      "path": "./dist/extension/scripts/popup-bundle.js",
+      "path": "./dist/extension-chrome/scripts/popup-bundle.js",
       "maxSize": "15 kB"
     },
     {
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index cb3526ed5..edac8142b 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -225,6 +225,13 @@
             "scoreDisplayMode": "notApplicable",
             "title": "Document has a valid `rel=canonical`"
         },
+        "charset": {
+            "description": "A character encoding declaration is required. It can be done with a <meta> tagin the first 1024 bytes of the HTML or in the Content-Type HTTP response header. [Learn more](https://www.w3.org/International/questions/qa-html-encoding-declarations).",
+            "id": "charset",
+            "score": 0.0,
+            "scoreDisplayMode": "binary",
+            "title": "Charset declaration is missing or occurs too late in the HTML"
+        },
         "color-contrast": {
             "description": "Low-contrast text is difficult or impossible for many users to read. [Learn more](https://web.dev/color-contrast/).",
             "details": {
@@ -3723,6 +3730,10 @@
                     "id": "doctype",
                     "weight": 1.0
                 },
+                {
+                    "id": "charset",
+                    "weight": 1.0
+                },
                 {
                     "id": "no-vulnerable-libraries",
                     "weight": 1.0
@@ -5226,6 +5237,12 @@
                 "name": "lh:audit:doctype",
                 "startTime": 0.0
             },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:audit:charset",
+                "startTime": 0.0
+            },
             {
                 "duration": 100.0,
                 "entryType": "measure",
diff --git a/tsconfig.json b/tsconfig.json
index 70f307899..2017a56e7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,7 @@
     "lighthouse-core/test/**/*.js",
     "clients/test/**/*.js",
     "lighthouse-cli/test/fixtures/**/*.js",
+    "lighthouse-core/scripts/legacy-javascript/variants",
   ],
   "files": [
     // Opt-in to typechecking for some core tests.
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index fd6e036a3..59207cfc5 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -75,7 +75,7 @@ declare global {
       /** All the link elements on the page or equivalently declared in `Link` headers. @see https://html.spec.whatwg.org/multipage/links.html */
       LinkElements: Artifacts.LinkElement[];
       /** The values of the <meta> elements in the head. */
-      MetaElements: Array<{name: string, content?: string, property?: string}>;
+      MetaElements: Array<{name?: string, content?: string, property?: string, httpEquiv?: string, charset?: string}>;
       /** Set of exceptions thrown during page load. */
       RuntimeExceptions: Crdp.Runtime.ExceptionThrownEvent[];
       /** Information on all script elements in the page. Also contains the content of all requested scripts and the networkRecord requestId that contained their content. Note, HTML documents will have one entry per script tag, all with the same requestId. */
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index b5dfef7de..9e8810971 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -88,8 +88,11 @@ declare global {
       // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change.
 
       export interface TableColumnHeading {
-        /** The name of the property within items being described. */
-        key: string;
+        /**
+         * The name of the property within items being described.
+         * If null, subRows must be defined, and the first sub-row will be empty.
+         */
+        key: string|null;
         /** Readable text label of the field. */
         text: string;
         /**
@@ -114,8 +117,11 @@ declare global {
       }
 
       export interface OpportunityColumnHeading {
-        /** The name of the property within items being described. */
-        key: string;
+        /**
+         * The name of the property within items being described.
+         * If null, subRows must be defined, and the first sub-row will be empty.
+         */
+        key: string|null;
         /** Readable text label of the field. */
         label: string;
         /**
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 0b33886b6..38898e4f2 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -55,6 +55,8 @@ declare global {
       description: string;
       /** A list of the members of LH.Artifacts that must be present for the audit to execute. */
       requiredArtifacts: Array<keyof Artifacts>;
+      /** A list of the members of LH.Artifacts that augment the audit, but aren't necessary. For internal use only with experimental-config. */
+      __internalOptionalArtifacts?: Array<keyof Artifacts>;
       /** A string identifying how the score should be interpreted for display. */
       scoreDisplayMode?: Audit.ScoreDisplayMode;
     }
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index caa4e7746..4090490ff 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -29,8 +29,21 @@
     runSerially?: boolean;
   }
 
-  export interface FirehouseOptions {
-    runLighthouse: (url: string, config?: LH.Config.Json) => Promise<Omit<LH.RunnerResult, 'report'>>;
+  export type LighthouseRunner =
+    (url: string, configJson?: LH.Config.Json, runnerOptions?: {isDebug?: boolean}) => Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>;
+
+  export interface SmokehouseOptions {
+    /** If true, performs extra logging from the test runs. */
+    isDebug?: boolean;
+    /** Manually set the number of jobs to run at once. `1` runs all tests serially. */
+    jobs?: number;
+    /** The number of times to retry failing tests before accepting. Defaults to 0. */
+    retries?: number;
+    /** A function that runs Lighthouse with the given options. Defaults to running Lighthouse via the CLI. */
+    lighthouseRunner?: LighthouseRunner;
+  }
+
+  export interface SmokehouseLibOptions extends SmokehouseOptions {
     urlFilterRegex?: RegExp;
     skip?: (test: TestDfn, expectation: ExpectedRunnerResult) => string | false;
     modify?: (test: TestDfn, expectation: ExpectedRunnerResult) => void;


Test patch:
diff --git a/clients/test/extension/popup-test.js b/clients/test/extension/popup-test.js
index c7ad3d468..a1f3c3c4d 100644
--- a/clients/test/extension/popup-test.js
+++ b/clients/test/extension/popup-test.js
@@ -12,7 +12,7 @@ const puppeteer = require('../../../node_modules/puppeteer/index.js');
 const {DEFAULT_CATEGORIES, STORAGE_KEYS} =
   require('../../extension/scripts/settings-controller.js');
 
-const lighthouseExtensionPath = path.resolve(__dirname, '../../../dist/extension');
+const lighthouseExtensionPath = path.resolve(__dirname, '../../../dist/extension-chrome');
 
 const mockStorage = {
   [STORAGE_KEYS.Categories]: {
@@ -68,6 +68,11 @@ describe('Lighthouse chrome popup', function() {
           getManifest: () => ({}),
         }),
       });
+      Object.defineProperty(chrome, 'i18n', {
+        get: () => ({
+          getMessage: () => '__LOCALIZED_STRING__',
+        }),
+      });
     }, mockStorage);
 
     await page.goto('file://' + path.join(lighthouseExtensionPath, 'popup.html'), {waitUntil: 'networkidle2'});
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index ea4a32e6f..e8a3ca1ff 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -351,6 +351,9 @@ Object {
     Object {
       "path": "dobetterweb/doctype",
     },
+    Object {
+      "path": "dobetterweb/charset",
+    },
     Object {
       "path": "dobetterweb/dom-size",
     },
@@ -707,6 +710,10 @@ Object {
           "id": "doctype",
           "weight": 1,
         },
+        Object {
+          "id": "charset",
+          "weight": 1,
+        },
         Object {
           "id": "no-vulnerable-libraries",
           "weight": 1,
diff --git a/lighthouse-cli/test/fixtures/legacy-javascript.html b/lighthouse-cli/test/fixtures/legacy-javascript.html
new file mode 100644
index 000000000..e4a6c8fa7
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/legacy-javascript.html
@@ -0,0 +1,14 @@
+<!--
+ * Copyright 2020 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.
+-->
+<html>
+  <body>
+    wanna cracker?
+    <script src="/legacy-javascript.js"></script>
+    <script>
+      String.prototype.includes = function() {};
+    </script>
+  </body>
+</html>
diff --git a/lighthouse-cli/test/fixtures/legacy-javascript.js b/lighthouse-cli/test/fixtures/legacy-javascript.js
new file mode 100644
index 000000000..6fed4379d
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/legacy-javascript.js
@@ -0,0 +1,11 @@
+/**
+ * @license Copyright 2020 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.
+ */
+
+/*eslint-disable*/
+
+// This is `./lighthouse-core/scripts/legacy-javascript/variants/core-js-3-preset-env-esmodules/false/main.bundle.min.js`.
+
+(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,(function(r){var n=e[i][1][r];return o(n||r)}),p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){"use strict";function _toConsumableArray(arr){return _arrayWithoutHoles(arr)||_iterableToArray(arr)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance")}function _iterableToArray(iter){if(Symbol.iterator in Object(iter)||Object.prototype.toString.call(iter)==="[object Arguments]")return Array.from(iter)}function _arrayWithoutHoles(arr){if(Array.isArray(arr)){for(var i=0,arr2=new Array(arr.length);i<arr.length;i++){arr2[i]=arr[i]}return arr2}}function asyncGeneratorStep(gen,resolve,reject,_next,_throw,key,arg){try{var info=gen[key](arg);var value=info.value}catch(error){reject(error);return}if(info.done){resolve(value)}else{Promise.resolve(value).then(_next,_throw)}}function _asyncToGenerator(fn){return function(){var self=this,args=arguments;return new Promise((function(resolve,reject){var gen=fn.apply(self,args);function _next(value){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"next",value)}function _throw(err){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"throw",err)}_next(undefined)}))}}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor)){throw new TypeError("Cannot call a class as a function")}}var MyTestClass=function MyTestClass(){_classCallCheck(this,MyTestClass)};function reg(){return _reg.apply(this,arguments)}function _reg(){_reg=_asyncToGenerator(regeneratorRuntime.mark((function _callee(){return regeneratorRuntime.wrap((function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:_context.next=2;return 1;case 2:case"end":return _context.stop()}}}),_callee)})));return _reg.apply(this,arguments)}var spread=[1,2,3].concat([3,2,1]);reg.apply(void 0,_toConsumableArray(spread))},{}]},{},[1]);
\ No newline at end of file
diff --git a/lighthouse-cli/test/fixtures/static-server.js b/lighthouse-cli/test/fixtures/static-server.js
index 4f0c9e29f..f39c58aae 100644
--- a/lighthouse-cli/test/fixtures/static-server.js
+++ b/lighthouse-cli/test/fixtures/static-server.js
@@ -21,136 +21,188 @@ const HEADER_SAFELIST = new Set(['x-robots-tag', 'link']);
 
 const lhRootDirPath = path.join(__dirname, '../../../');
 
-function requestHandler(request, response) {
-  const requestUrl = parseURL(request.url);
-  const filePath = requestUrl.pathname;
-  const queryString = requestUrl.search && parseQueryString(requestUrl.search.slice(1));
-  let absoluteFilePath = path.join(__dirname, filePath);
-
-  // Create an index page that lists the available test pages.
-  if (filePath === '/') {
-    const fixturePaths = glob.sync('**/*.html', {cwd: __dirname});
-    const html = `
-      <html>
-      <h1>Smoke test fixtures</h1>
-      ${fixturePaths.map(p => `<a href=${encodeURI(p)}>${escape(p)}</a>`).join('<br>')}
-    `;
-    response.writeHead(200, {
-      'Content-Security-Policy': `default-src 'none';`,
-    });
-    sendResponse(200, html);
-    return;
+class Server {
+  constructor() {
+    this._server = http.createServer(this._requestHandler.bind(this));
+    /** @type {(data: string) => string=} */
+    this._dataTransformer = undefined;
   }
 
-  if (filePath.startsWith('/dist/viewer')) {
-    // Rewrite lighthouse-viewer paths to point to that location.
-    absoluteFilePath = path.join(__dirname, '/../../../', filePath);
+  getPort() {
+    return this._server.address().port;
   }
 
-  // Disallow file requests outside of LH folder
-  const filePathDir = path.parse(absoluteFilePath).dir;
-  if (!filePathDir.startsWith(lhRootDirPath)) {
-    return readFileCallback(new Error('Disallowed path'));
+  /**
+   * @param {=number} port
+   * @param {=string} hostname
+   */
+  listen(port, hostname) {
+    this._server.listen(port, hostname);
+    return new Promise((resolve, reject) => {
+      this._server.on('listening', resolve);
+      this._server.on('error', reject);
+    });
   }
 
-  // Check if the file exists, then read it and serve it.
-  fs.exists(absoluteFilePath, fsExistsCallback);
-
-  function fsExistsCallback(fileExists) {
-    if (!fileExists) {
-      return sendResponse(404, `404 - File not found. ${filePath}`);
-    }
-    fs.readFile(absoluteFilePath, 'binary', readFileCallback);
+  close() {
+    return new Promise((resolve, reject) => {
+      this._server.close(err => {
+        if (err) return reject(err);
+        resolve();
+      });
+    });
   }
 
-  function readFileCallback(err, file) {
-    if (err) {
-      console.error(`Unable to read local file ${absoluteFilePath}:`, err);
-      return sendResponse(500, '500 - Internal Server Error');
-    }
-    sendResponse(200, file);
+  /**
+   * @param {(data: string) => string=} fn
+   */
+  setDataTransformer(fn) {
+    this._dataTransformer = fn;
   }
 
-  function sendResponse(statusCode, data) {
-    const headers = {'Access-Control-Allow-Origin': '*'};
-
-    const contentType = mime.lookup(filePath);
-    const charset = mime.lookup(contentType);
-    // `mime.contentType` appends the correct charset too.
-    // Note: it seems to miss just one case, svg. Doesn't matter much, we'll just allow
-    // svgs to fallback to binary encoding. `Content-Type: image/svg+xml` is sufficient for our use case.
-    // see https://github.com/jshttp/mime-types/issues/66
-    if (contentType) headers['Content-Type'] = mime.contentType(contentType);
-
-    let delay = 0;
-    let useGzip = false;
-    if (queryString) {
-      const params = new URLSearchParams(queryString);
-      // set document status-code
-      if (params.has('status_code')) {
-        statusCode = parseInt(params.get('status_code'), 10);
-      }
+  _requestHandler(request, response) {
+    const requestUrl = parseURL(request.url);
+    const filePath = requestUrl.pathname;
+    const queryString = requestUrl.search && parseQueryString(requestUrl.search.slice(1));
+    let absoluteFilePath = path.join(__dirname, filePath);
+
+    const sendResponse = (statusCode, data) => {
+      // Used by Smokerider.
+      if (this._dataTransformer) data = this._dataTransformer(data);
+
+      const headers = {'Access-Control-Allow-Origin': '*'};
+
+      const contentType = mime.lookup(filePath);
+      const charset = mime.lookup(contentType);
+      // `mime.contentType` appends the correct charset too.
+      // Note: it seems to miss just one case, svg. Doesn't matter much, we'll just allow
+      // svgs to fallback to binary encoding. `Content-Type: image/svg+xml` is sufficient for our use case.
+      // see https://github.com/jshttp/mime-types/issues/66
+      if (contentType) headers['Content-Type'] = mime.contentType(contentType);
+
+      let delay = 0;
+      let useGzip = false;
+      if (queryString) {
+        const params = new URLSearchParams(queryString);
+        // set document status-code
+        if (params.has('status_code')) {
+          statusCode = parseInt(params.get('status_code'), 10);
+        }
 
-      // set delay of request when present
-      if (params.has('delay')) {
-        delay = parseInt(params.get('delay'), 10) || 2000;
-      }
+        // set delay of request when present
+        if (params.has('delay')) {
+          delay = parseInt(params.get('delay'), 10) || 2000;
+        }
 
-      if (params.has('extra_header')) {
-        const extraHeaders = new URLSearchParams(params.get('extra_header'));
-        for (const [headerName, headerValue] of extraHeaders) {
-          if (HEADER_SAFELIST.has(headerName.toLowerCase())) {
-            headers[headerName] = headers[headerName] || [];
-            headers[headerName].push(headerValue);
+        if (params.has('extra_header')) {
+          const extraHeaders = new URLSearchParams(params.get('extra_header'));
+          for (const [headerName, headerValue] of extraHeaders) {
+            if (HEADER_SAFELIST.has(headerName.toLowerCase())) {
+              headers[headerName] = headers[headerName] || [];
+              headers[headerName].push(headerValue);
+            }
           }
         }
+
+        if (params.has('gzip')) {
+          useGzip = Boolean(params.get('gzip'));
+        }
+
+        // redirect url to new url if present
+        if (params.has('redirect')) {
+          return setTimeout(sendRedirect, delay, params.get('redirect'));
+        }
       }
 
-      if (params.has('gzip')) {
-        useGzip = Boolean(params.get('gzip'));
+      if (useGzip) {
+        data = zlib.gzipSync(data);
+        headers['Content-Encoding'] = 'gzip';
+
+        // Set special header for Lightrider, needed for Smokerider.
+        // In production LR, this header is set by the production fetcher. For smokerider,
+        // a different fetcher is used, so we must set this header here instead, to excercies
+        // the parts of the LH netcode that expects this header.
+        // This _should_ be the byte size of the entire response
+        // (encoded content, headers, chunk overhead, etc.) but - a rough estimate is OK
+        // because the smoke test byte expectations have some wiggle room.
+        headers['X-TotalFetchedSize'] = Buffer.byteLength(data) + JSON.stringify(headers).length;
       }
 
-      // redirect url to new url if present
-      if (params.has('redirect')) {
-        return setTimeout(sendRedirect, delay, params.get('redirect'));
+      response.writeHead(statusCode, headers);
+
+      // Delay the response
+      if (delay > 0) {
+        return setTimeout(finishResponse, delay, data);
       }
+
+      const encoding = charset === 'UTF-8' ? 'utf-8' : 'binary';
+      finishResponse(data, encoding);
+    };
+
+    // Create an index page that lists the available test pages.
+    if (filePath === '/') {
+      const fixturePaths = glob.sync('**/*.html', {cwd: __dirname});
+      const html = `
+        <html>
+        <h1>Smoke test fixtures</h1>
+        ${fixturePaths.map(p => `<a href=${encodeURI(p)}>${escape(p)}</a>`).join('<br>')}
+      `;
+      response.writeHead(200, {
+        'Content-Security-Policy': `default-src 'none';`,
+      });
+      sendResponse(200, html);
+      return;
     }
 
-    if (useGzip) {
-      data = zlib.gzipSync(data);
-      headers['Content-Encoding'] = 'gzip';
+    if (filePath.startsWith('/dist/viewer')) {
+      // Rewrite lighthouse-viewer paths to point to that location.
+      absoluteFilePath = path.join(__dirname, '/../../../', filePath);
     }
 
-    response.writeHead(statusCode, headers);
+    // Disallow file requests outside of LH folder
+    const filePathDir = path.parse(absoluteFilePath).dir;
+    if (!filePathDir.startsWith(lhRootDirPath)) {
+      return readFileCallback(new Error('Disallowed path'));
+    }
 
-    // Delay the response
-    if (delay > 0) {
-      return setTimeout(finishResponse, delay, data);
+    // Check if the file exists, then read it and serve it.
+    fs.exists(absoluteFilePath, fsExistsCallback);
+
+    function fsExistsCallback(fileExists) {
+      if (!fileExists) {
+        return sendResponse(404, `404 - File not found. ${filePath}`);
+      }
+      fs.readFile(absoluteFilePath, 'binary', readFileCallback);
     }
 
-    const encoding = charset === 'UTF-8' ? 'utf-8' : 'binary';
-    finishResponse(data, encoding);
-  }
+    function readFileCallback(err, file) {
+      if (err) {
+        console.error(`Unable to read local file ${absoluteFilePath}:`, err);
+        return sendResponse(500, '500 - Internal Server Error');
+      }
+      sendResponse(200, file);
+    }
 
-  function sendRedirect(url) {
-    const headers = {
-      Location: url,
-    };
-    response.writeHead(302, headers);
-    response.end();
-  }
+    function sendRedirect(url) {
+      const headers = {
+        Location: url,
+      };
+      response.writeHead(302, headers);
+      response.end();
+    }
 
-  function finishResponse(data, encoding) {
-    response.write(data, encoding);
-    response.end();
+    function finishResponse(data, encoding) {
+      response.write(data, encoding);
+      response.end();
+    }
   }
 }
 
-const serverForOnline = http.createServer(requestHandler);
-const serverForOffline = http.createServer(requestHandler);
+const serverForOnline = new Server();
+const serverForOffline = new Server();
 
-serverForOnline.on('error', e => console.error(e.code, e));
-serverForOffline.on('error', e => console.error(e.code, e));
+serverForOnline._server.on('error', e => console.error(e.code, e));
+serverForOffline._server.on('error', e => console.error(e.code, e));
 
 // If called via `node static-server.js` then start listening, otherwise, just expose the servers
 if (require.main === module) {
@@ -167,4 +219,3 @@ if (require.main === module) {
     serverForOffline,
   };
 }
-
diff --git a/lighthouse-cli/test/smokehouse/firehouse.js b/lighthouse-cli/test/smokehouse/firehouse.js
deleted file mode 100644
index 0db7effb7..000000000
--- a/lighthouse-cli/test/smokehouse/firehouse.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @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';
-
-/**
- * @fileoverview Smoke test runner.
- * Used to test channels other than npm (`run-smoke.js` handles that).
- * Supports skipping and modifiying expectations to match the environment.
- */
-
-/* eslint-disable no-console */
-
-const log = require('lighthouse-logger');
-const smokeTests = require('./test-definitions/core-tests.js');
-const report = require('./report-assert.js');
-
-/**
- * @param {Smokehouse.FirehouseOptions} options
- */
-async function runSmokes(options) {
-  const {runLighthouse, urlFilterRegex, skip, modify} = options;
-
-  let passingCount = 0;
-  let failingCount = 0;
-
-  for (const test of smokeTests) {
-    for (const expected of test.expectations) {
-      if (urlFilterRegex && !expected.lhr.requestedUrl.match(urlFilterRegex)) {
-        continue;
-      }
-
-      console.log(`====== ${expected.lhr.requestedUrl} ======`);
-      const reasonToSkip = skip && skip(test, expected);
-      if (reasonToSkip) {
-        console.log(`skipping: ${reasonToSkip}`);
-        continue;
-      }
-
-      modify && modify(test, expected);
-      const results = await runLighthouse(expected.lhr.requestedUrl, test.config);
-      console.log(`Asserting expected results match those found. (${expected.lhr.requestedUrl})`);
-      const counts = report(results, expected);
-      passingCount += counts.passed;
-      failingCount += counts.failed;
-      // Log buffered report logging.
-      console.log(counts.log);
-    }
-  }
-
-  if (passingCount) {
-    console.log(log.greenify(`${passingCount} passing`));
-  }
-  if (failingCount) {
-    console.log(log.redify(`${failingCount} failing`));
-  }
-
-  return {
-    success: passingCount > 0 && failingCount === 0,
-    passingCount,
-    failingCount,
-  };
-}
-
-module.exports = {
-  runSmokes,
-};
diff --git a/lighthouse-cli/test/smokehouse/frontends/lib.js b/lighthouse-cli/test/smokehouse/frontends/lib.js
new file mode 100644
index 000000000..b1f53be7e
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/frontends/lib.js
@@ -0,0 +1,53 @@
+/**
+ * @license Copyright 2020 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';
+
+/**
+ * @fileoverview Smoke test runner.
+ * Used to test integrations that run Lighthouse within a browser (i.e. LR, DevTools)
+ * Supports skipping and modifiying expectations to match the environment.
+ */
+
+/* eslint-disable no-console */
+
+const smokeTests = require('../test-definitions/core-tests.js');
+const {runSmokehouse} = require('../smokehouse.js');
+
+/**
+ * @param {Smokehouse.SmokehouseLibOptions} options
+ */
+async function smokehouse(options) {
+  const {urlFilterRegex, skip, modify, ...smokehouseOptions} = options;
+
+  /** @type {Smokehouse.TestDfn[]} */
+  const clonedTests = JSON.parse(JSON.stringify(smokeTests));
+  const modifiedTests = clonedTests.map(test => {
+    const modifiedExpectations = [];
+    for (const expected of test.expectations) {
+      if (urlFilterRegex && !expected.lhr.requestedUrl.match(urlFilterRegex)) {
+        continue;
+      }
+
+      const reasonToSkip = skip && skip(test, expected);
+      if (reasonToSkip) {
+        console.log(`skipping ${expected.lhr.requestedUrl}: ${reasonToSkip}`);
+        continue;
+      }
+
+      modify && modify(test, expected);
+      modifiedExpectations.push(expected);
+    }
+
+    return {
+      ...test,
+      expectations: modifiedExpectations,
+    };
+  }).filter(test => test.expectations.length > 0);
+
+  return runSmokehouse(modifiedTests, smokehouseOptions);
+}
+
+module.exports = smokehouse;
diff --git a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
index 002e1355d..bb9ddd170 100644
--- a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
+++ b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
@@ -107,10 +107,10 @@ async function begin() {
   try {
     server.listen(10200, 'localhost');
     serverForOffline.listen(10503, 'localhost');
-    isPassing = await runSmokehouse(testDefns, options);
+    isPassing = (await runSmokehouse(testDefns, options)).success;
   } finally {
-    await new Promise(resolve => server.close(resolve));
-    await new Promise(resolve => serverForOffline.close(resolve));
+    await server.close();
+    await serverForOffline.close();
   }
 
   const exitCode = isPassing ? 0 : 1;
diff --git a/lighthouse-cli/test/smokehouse/readme.md b/lighthouse-cli/test/smokehouse/readme.md
index 8d549e44c..1025dc15c 100644
--- a/lighthouse-cli/test/smokehouse/readme.md
+++ b/lighthouse-cli/test/smokehouse/readme.md
@@ -91,8 +91,8 @@ Smokehouse Frontends                                        Lighthouse Runners
                   |                    ^                  +-->+   bundle.js  |
 +------------+    |                    |                      |              |
 |            |    |                    |                      +--------------+
-|bundle+entry+----+                    v
-|   (TODO)   |                +--------+--------+
+|   lib.js   +----+                    v
+|            |                +--------+--------+
 +------------+                |                 |
                               |  report/assert  |
                               |                 |
@@ -102,7 +102,7 @@ Smokehouse Frontends                                        Lighthouse Runners
 ### Smokehouse frontends
 
 - `frontends/smokehouse-bin.js` - runs smokehouse from the command line
-- TODO: bundle-entry - simple entrypoint to smokehouse for bundling and running in a browser.
+- `lib` - configurable entrypoint to smokehouse, can be bundled to run in a browser environment
 - `node.js` - run smokehouse from a node process
 
 ### Smokehouse
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 08ffbce4f..3ff26a068 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -26,26 +26,17 @@ const DEFAULT_CONCURRENT_RUNS = 5;
 const DEFAULT_RETRIES = 0;
 
 /**
- * @typedef SmokehouseOptions
- * @property {boolean=} isDebug If true, performs extra logging from the test runs.
- * @property {number=} jobs Manually set the number of jobs to run at once. `1` runs all tests serially.
- * @property {number=} retries The number of times to retry failing tests before accepting. Defaults to 0.
- * @property {LighthouseRunner=} lighthouseRunner A function that runs Lighthouse with the given options. Defaults to running Lighthouse via the CLI.
- */
-
-/**
- * @callback LighthouseRunner
- * @param {string} url
- * @param {LH.Config.Json=} configJson
- * @param {{isDebug?: boolean}=} runnerOptions
- * @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
+ * @typedef SmokehouseResult
+ * @property {string} id
+ * @property {boolean} success
+ * @property {Array<{passed: number, failed: number, log: string}>} expectationResults
  */
 
 /**
  * Runs the selected smoke tests. Returns whether all assertions pass.
  * @param {Array<Smokehouse.TestDfn>} smokeTestDefns
- * @param {SmokehouseOptions=} smokehouseOptions
- * @return {Promise<boolean>}
+ * @param {Smokehouse.SmokehouseOptions=} smokehouseOptions
+ * @return {Promise<{success: boolean, testResults: SmokehouseResult[]}>}
  */
 async function runSmokehouse(smokeTestDefns, smokehouseOptions = {}) {
   const {
@@ -67,17 +58,32 @@ async function runSmokehouse(smokeTestDefns, smokehouseOptions = {}) {
     const result = runSmokeTestDefn(concurrentMapper, testDefn, options);
     smokePromises.push(result);
   }
-  const smokeResults = await Promise.all(smokePromises);
+  const testResults = await Promise.all(smokePromises);
+
+  let passingCount = 0;
+  let failingCount = 0;
+  for (const testResult of testResults) {
+    for (const expectationResult of testResult.expectationResults) {
+      passingCount += expectationResult.passed;
+      failingCount += expectationResult.failed;
+    }
+  }
+  if (passingCount) {
+    console.log(log.greenify(`${passingCount} expectations passing`));
+  }
+  if (failingCount) {
+    console.log(log.redify(`${failingCount} expectations failing`));
+  }
 
   // Print and fail if there were failing tests.
-  const failingDefns = smokeResults.filter(result => !result.success);
+  const failingDefns = testResults.filter(result => !result.success);
   if (failingDefns.length) {
     const testNames = failingDefns.map(d => d.id).join(', ');
     console.error(log.redify(`We have ${failingDefns.length} failing smoketests: ${testNames}`));
-    return false;
+    return {success: false, testResults};
   }
 
-  return true;
+  return {success: true, testResults};
 }
 
 /**
@@ -104,8 +110,8 @@ function assertNonNegativeInteger(loggableName, value) {
  * once all are finished.
  * @param {ConcurrentMapper} concurrentMapper
  * @param {Smokehouse.TestDfn} smokeTestDefn
- * @param {{concurrency: number, retries: number, lighthouseRunner: LighthouseRunner, isDebug?: boolean}} defnOptions
- * @return {Promise<{id: string, success: boolean}>}
+ * @param {{concurrency: number, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, isDebug?: boolean}} defnOptions
+ * @return {Promise<SmokehouseResult>}
  */
 async function runSmokeTestDefn(concurrentMapper, smokeTestDefn, defnOptions) {
   const {id, config: configJson, expectations} = smokeTestDefn;
@@ -153,6 +159,7 @@ async function runSmokeTestDefn(concurrentMapper, smokeTestDefn, defnOptions) {
   return {
     id,
     success: failingTestCount === 0,
+    expectationResults: results,
   };
 }
 
@@ -164,7 +171,7 @@ function purpleify(str) {
 /**
  * Run Lighthouse in the selected runner. Returns `log`` for logging once
  * all tests in a defn are complete.
- * @param {{requestedUrl: string, configJson?: LH.Config.Json, expectation: Smokehouse.ExpectedRunnerResult, lighthouseRunner: LighthouseRunner, retries: number, isDebug?: boolean}} testOptions
+ * @param {{requestedUrl: string, configJson?: LH.Config.Json, expectation: Smokehouse.ExpectedRunnerResult, lighthouseRunner: Smokehouse.LighthouseRunner, retries: number, isDebug?: boolean}} testOptions
  * @return {Promise<{passed: number, failed: number, log: string}>}
  */
 async function runSmokeTest(testOptions) {
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
index b6115e8b0..98231b87f 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
@@ -70,6 +70,11 @@ const smokeTests = [{
   id: 'mixed-content',
   expectations: require('./mixed-content/mixed-content-expectations.js'),
   config: require('../../../../lighthouse-core/config/mixed-content-config.js'),
+},
+{
+  id: 'legacy-javascript',
+  expectations: require('./legacy-javascript/expectations.js'),
+  config: require('./legacy-javascript/legacy-javascript-config.js'),
 }];
 
 module.exports = smokeTests;
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
index 5889a1a87..06475854b 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
@@ -128,6 +128,7 @@ const expectations = [
         {
           name: '',
           content: '',
+          charset: 'utf-8',
         },
         {
           name: 'viewport',
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js
new file mode 100644
index 000000000..63c375958
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js
@@ -0,0 +1,40 @@
+/**
+ * @license Copyright 2020 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';
+
+/**
+ * Expected Lighthouse audit values for sites with polyfills.
+ */
+module.exports = [
+  {
+    lhr: {
+      requestedUrl: 'http://localhost:10200/legacy-javascript.html',
+      finalUrl: 'http://localhost:10200/legacy-javascript.html',
+      audits: {
+        'legacy-javascript': {
+          details: {
+            items: [
+              {
+                url: 'http://localhost:10200/legacy-javascript.js',
+                signals: [
+                  '@babel/plugin-transform-classes',
+                  '@babel/plugin-transform-regenerator',
+                  '@babel/plugin-transform-spread',
+                ],
+              },
+              {
+                url: 'http://localhost:10200/legacy-javascript.html',
+                signals: [
+                  'String.prototype.includes',
+                ],
+              },
+            ],
+          },
+        },
+      },
+    },
+  },
+];
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js
new file mode 100644
index 000000000..f593c177e
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js
@@ -0,0 +1,23 @@
+/**
+ * @license Copyright 2020 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';
+
+/**
+ * Config file for running PWA smokehouse audits.
+ */
+module.exports = {
+  extends: 'lighthouse:default',
+  settings: {
+    onlyCategories: [
+      'performance',
+    ],
+    onlyAudits: [
+      'legacy-javascript',
+    ],
+  },
+  // Not in default yet.
+  audits: ['legacy-javascript'],
+};
diff --git a/lighthouse-cli/test/static-server-test.js b/lighthouse-cli/test/static-server-test.js
new file mode 100644
index 000000000..a65bdea65
--- /dev/null
+++ b/lighthouse-cli/test/static-server-test.js
@@ -0,0 +1,43 @@
+/**
+ * @license Copyright 2020 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 fs = require('fs');
+const fetch = require('isomorphic-fetch');
+const {server} = require('./fixtures/static-server.js');
+
+/* eslint-env jest */
+
+describe('Server', () => {
+  beforeAll(async () => {
+    await server.listen(10200, 'localhost');
+  });
+
+  afterAll(async () => {
+    await server.close();
+  });
+
+  afterEach(() => {
+    server.setDataTransformer(undefined);
+  });
+
+  it('fetches fixture', async () => {
+    const res = await fetch(`http://localhost:${server.getPort()}/dobetterweb/dbw_tester.html`);
+    const data = await res.text();
+    const expected = fs.readFileSync(`${__dirname}/fixtures/dobetterweb/dbw_tester.html`, 'utf-8');
+    expect(data).toEqual(expected);
+  });
+
+  it('setDataTransformer', async () => {
+    server.setDataTransformer(() => {
+      return 'hello there';
+    });
+
+    const res = await fetch(`http://localhost:${server.getPort()}/dobetterweb/dbw_tester.html`);
+    const data = await res.text();
+    expect(data).toEqual('hello there');
+  });
+});
diff --git a/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
new file mode 100644
index 000000000..c6c05cfb1
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
@@ -0,0 +1,103 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+/**
+ * @fileoverview - Used to manually examine the polyfills/transforms used on a page.
+ *
+ * USAGE:
+ *   1. Run `yarn start <url to examine> -G
+ *   2. Run `node ./lighthouse-core/scripts/legacy-javascript/examine-latest-run.js`
+ *   3. Inspect output for fishy looking polyfills.
+ */
+
+const path = require('path');
+// @ts-ignore - We don't really need types for this
+const colors = require('colors');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+
+const LH_ROOT_DIR = path.join(__dirname, '../../../');
+const LATEST_RUN_DIR = path.join(LH_ROOT_DIR, 'latest-run');
+
+/** @param {LH.DevtoolsLog} log */
+function requestUrlToId(log) {
+  return log.reduce(
+    (map, entry) => {
+      if (entry.method === 'Network.requestWillBeSent') {
+        map[entry.params.request.url] = entry.params.requestId;
+      }
+      return map;
+    },
+    /** @type {Record<string, string>} */ ({})
+  );
+}
+
+async function main() {
+  /** @type {LH.Artifacts} */
+  const artifacts = require(`${LATEST_RUN_DIR}/artifacts.json`);
+  const devtoolsLog = require(`${LATEST_RUN_DIR}/defaultPass.devtoolslog.json`);
+  const scripts = artifacts.ScriptElements;
+  const requestUrlMap = requestUrlToId(devtoolsLog);
+  artifacts.devtoolsLogs = {defaultPass: devtoolsLog};
+
+  const auditResults = await LegacyJavascript.audit(artifacts, {
+    computedCache: new Map(),
+    options: {},
+    settings: /** @type {any} */ ({}),
+    LighthouseRunWarnings: [],
+  });
+
+  const items =
+    auditResults.details &&
+    auditResults.details.type === 'table' &&
+    auditResults.details.items;
+
+  if (!items) {
+    console.log('No signals found!');
+    return;
+  }
+
+  console.log(colors.bold(`${items.length} signals found!`));
+  for (const item of items) {
+    if (typeof item.url !== 'string') continue;
+    const requestId = requestUrlMap[item.url];
+    const script = scripts.find(s => s.requestId === requestId);
+    const signals = Array.isArray(item.signals) ? item.signals : [];
+    const locations = Array.isArray(item.locations) ? item.locations : [];
+
+    console.log('---------------------------------');
+    console.log(`URL: ${item.url}`);
+    console.log(`Signals: ${signals.length}`);
+    if (!script || !script.content) {
+      console.log('\nFailed to find script content! :/');
+      console.log('---------------------------------\n\n');
+      continue;
+    }
+
+    const lines = script.content.split('\n');
+    for (let i = 0; i < signals.length; i++) {
+      const signal = signals[i];
+      const location = locations[i];
+      if (typeof location !== 'object' || location.type !== 'source-location') {
+        continue;
+      }
+
+      const line = lines[location.line || 0] || '';
+      const locationString = `at ${location.line}:${location.column}`;
+      console.log('');
+      console.log(`${signal} ${colors.dim(locationString)}`);
+      const contentToShow = line.slice(location.column - 10, location.column + 80);
+      const unimportant = contentToShow.split(signal.toString());
+      console.log(unimportant.map(s => colors.dim(s)).join(signal.toString()));
+    }
+
+    console.log('---------------------------------\n\n');
+  }
+}
+
+main();
diff --git a/lighthouse-core/scripts/test-legacy-javascript.sh b/lighthouse-core/scripts/test-legacy-javascript.sh
new file mode 100644
index 000000000..aafe9f8ee
--- /dev/null
+++ b/lighthouse-core/scripts/test-legacy-javascript.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT="$DIRNAME/../.."
+cd $LH_ROOT
+
+set -e
+
+# This test can be expensive, we'll only run the tests if we touched files that affect the simulations.
+CHANGED_FILES=""
+if [[ "$CI" ]]; then
+  CHANGED_FILES=$(git --no-pager diff --name-only $TRAVIS_COMMIT_RANGE)
+else
+  CHANGED_FILES=$(git --no-pager diff --name-only master)
+fi
+
+printf "Determined the following files have been touched:\n\n$CHANGED_FILES\n\n"
+
+if ! echo $CHANGED_FILES | grep -E 'legacy-javascript' > /dev/null; then
+  echo "No legacy-javascript files affected, skipping test."
+  exit 0
+fi
+
+printf "\n\nRunning test...\n"
+cd "$LH_ROOT/lighthouse-core/scripts/legacy-javascript"
+yarn
+node run.js
diff --git a/lighthouse-core/test/audits/dobetterweb/charset-test.js b/lighthouse-core/test/audits/dobetterweb/charset-test.js
new file mode 100644
index 000000000..bc77ec36f
--- /dev/null
+++ b/lighthouse-core/test/audits/dobetterweb/charset-test.js
@@ -0,0 +1,165 @@
+/**
+ * @license Copyright 2020 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 CharsetDefinedAudit = require('../../../audits/dobetterweb/charset.js');
+const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
+
+/* eslint-env jest */
+
+const HTML_PRE = '<!doctype html><head>';
+const HTML_POST = '</head><body><h1>hello';
+
+function generateArtifacts(htmlContent, contentTypeValue = 'text/html') {
+  const finalUrl = 'https://example.com/';
+  const mainResource = {
+    url: finalUrl,
+    responseHeaders: [
+      {name: 'content-type', value: contentTypeValue},
+    ],
+  };
+  const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
+  const context = {computedCache: new Map()};
+  return [{
+    devtoolsLogs: {[CharsetDefinedAudit.DEFAULT_PASS]: devtoolsLog},
+    URL: {finalUrl},
+    MainDocumentContent: htmlContent,
+    MetaElements: [],
+  }, context];
+}
+
+describe('Charset defined audit', () => {
+  it('succeeds where the page contains the charset meta tag', async () => {
+    const htmlContent = HTML_PRE + '<meta charset="utf-8" >' + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    artifacts.MetaElements = [{name: '', content: '', charset: 'utf-8'}];
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('succeeds when the page has the charset defined in the content-type meta tag', async () => {
+    const htmlContent = HTML_PRE +
+      '<meta http-equiv="Content-type" content="text/html; charset=utf-8" />' + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    artifacts.MetaElements = [
+      {name: '', content: 'text/html; charset=utf-8', httpEquiv: 'content-type'},
+    ];
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('succeeds when the page has the charset defined in the content-type http header', async () => {
+    const htmlContent = HTML_PRE +
+      '<meta http-equiv="Content-type" content="text/html" />' + HTML_POST;
+    const contentTypeVal = 'text/html; charset=UTF-8';
+    const [artifacts, context] = generateArtifacts(htmlContent, contentTypeVal);
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('succeeds when the page has the charset defined via BOM', async () => {
+    const htmlContent = '\ufeff' + HTML_PRE +
+      '<meta http-equiv="Content-type" content="text/html" />' + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('fails when the page does not have charset defined', async () => {
+    const htmlContent = HTML_PRE + '<meta http-equiv="Content-type" content="text/html" />';
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 0);
+  });
+
+  it('fails when the page has charset defined too late in the page', async () => {
+    const bigString = new Array(1024).fill(' ').join('');
+    const htmlContent = HTML_PRE + bigString + '<meta charset="utf-8" />' + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    artifacts.MetaElements = [{name: '', content: '', charset: 'utf-8'}];
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 0);
+  });
+
+  it('passes when the page has charset defined almost too late in the page', async () => {
+    const bigString = new Array(900).fill(' ').join('');
+    const htmlContent = HTML_PRE + bigString + '<meta charset="utf-8" />' + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    artifacts.MetaElements = [{name: '', content: '', charset: 'utf-8'}];
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('fails when charset only partially defined in the first 1024 bytes of the page', async () => {
+    const charsetHTML = '<meta charset="utf-8" />';
+    // 1024 bytes should be halfway through the meta tag
+    const bigString = new Array(1024 - HTML_PRE.length - charsetHTML.length / 2).fill(' ').join('');
+    const htmlContent = HTML_PRE + bigString + charsetHTML + HTML_POST;
+    const [artifacts, context] = generateArtifacts(htmlContent);
+    artifacts.MetaElements = [{name: '', content: '', charset: 'utf-8'}];
+    const auditResult = await CharsetDefinedAudit.audit(artifacts, context);
+    assert.equal(auditResult.score, 0);
+  });
+});
+
+describe('Charset regex check', () => {
+  const HTML_REGEX = CharsetDefinedAudit.CHARSET_HTML_REGEX;
+  const HTTP_REGEX = CharsetDefinedAudit.CHARSET_HTTP_REGEX;
+  const IANA_REGEX = CharsetDefinedAudit.IANA_REGEX;
+
+  it('handles html correctly', () => {
+    // Positive cases
+    assert.equal(HTML_REGEX.test('<meta charset=utf-8 />'), true);
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta charset=utf-8 /><body>`), true);
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta   charset=utf-8  /><body>`), true);
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta charset=utf-8><body>`), true);
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta charset=UTF-8><body>`), true);
+    assert.equal(HTML_REGEX.test(
+      `<!doctype html><meta http-equiv="Content-type" content="text/html; charset=utf-8"/><body>'`),
+      true);
+    assert.equal(HTML_REGEX.test(
+      `<!doctype html><meta content="text/html; charset=utf-8" http-equiv="Content-type"/><body>'`),
+      true);
+
+    // Negative cases
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta description=hello><body>`), false);
+    assert.equal(HTML_REGEX.test(`<!doctype html><meta charset=utf-8<body>`), false);
+    assert.equal(HTML_REGEX.test(
+      `<!doctype html><meta http-equiv="Content-type" content="text/html; nope-tf8" /><body>'`),
+      false);
+    assert.equal(HTML_REGEX.test(
+      `<!doctype html><meta http-equiv="Content-type" content="text/html; charset=utf-8" <body>'`),
+      false);
+  });
+
+  it('handles http header correctly', () => {
+    // Positive cases
+    assert.equal(HTTP_REGEX.test('text/html; charset=UTF-8'), true);
+    assert.equal(HTTP_REGEX.test('text/html; charset = UTF-8'), true);
+
+    // Negative cases
+    assert.equal(HTTP_REGEX.test('text/html; charset='), false);
+    assert.equal(HTTP_REGEX.test('text/html; charset=x'), false);
+    assert.equal(HTTP_REGEX.test('text/html; charset=  '), false);
+  });
+
+  it('handles charset name validation correctly', () => {
+    // Positive cases
+    assert.equal(IANA_REGEX.test('utf-8'), true);
+    assert.equal(IANA_REGEX.test('utf-16'), true);
+    assert.equal(IANA_REGEX.test('IT'), true);
+    assert.equal(IANA_REGEX.test('NS_4551-1'), true);
+    assert.equal(IANA_REGEX.test('ISO_646.basic:1983'), true);
+    assert.equal(IANA_REGEX.test('NF_Z_62-010_(1973)'), true);
+
+    // Negative cases
+    assert.equal(IANA_REGEX.test('a'), false);
+    assert.equal(IANA_REGEX.test(''), false);
+    assert.equal(IANA_REGEX.test('utf+8'), false);
+    assert.equal(IANA_REGEX.test('utf-16*'), false);
+  });
+});
diff --git a/lighthouse-core/test/audits/legacy-javascript-test.js b/lighthouse-core/test/audits/legacy-javascript-test.js
new file mode 100644
index 000000000..80d291c48
--- /dev/null
+++ b/lighthouse-core/test/audits/legacy-javascript-test.js
@@ -0,0 +1,167 @@
+/**
+ * @license Copyright 2020 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 assert = require('assert');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
+
+/**
+ * @param {Array<{url: string, code: string}>} scripts
+ * @return {LH.Artifacts}
+ */
+const createArtifacts = (scripts) => {
+  const networkRecords = scripts.map(({url}, index) => ({
+    requestId: String(index),
+    url,
+  }));
+  return {
+    URL: {finalUrl: '', requestedUrl: ''},
+    devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)},
+    ScriptElements: scripts.reduce((acc, {code}, index) => {
+      acc[String(index)] = {
+        content: code,
+        requestId: String(index),
+      };
+      return acc;
+    }, {}),
+  };
+};
+
+/**
+ * @param {string[]} codeSnippets
+ * @return {string[]}
+ */
+const createVariants = (codeSnippets) => {
+  const variants = [];
+
+  for (const codeSnippet of codeSnippets) {
+    // Explicitly don't create a variant for just `codeSnippet`,
+    // because making the patterns work with a starting anchor (^)
+    // complicates the expressions more than its worth.
+    variants.push(`;${codeSnippet}`);
+    variants.push(` ${codeSnippet}`);
+  }
+
+  return variants;
+};
+
+/* eslint-env jest */
+describe('LegacyJavaScript audit', () => {
+  it('passes code with no polyfills', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'var message = "hello world"; console.log(message);',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'SomeGlobal = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'SomeClass.prototype.someFn = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'Object.defineProperty(SomeClass.prototype, "someFn", function() {})',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 1);
+    assert.equal(result.extendedInfo.signalCount, 0);
+  });
+
+  it('fails code with a legacy polyfill', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'String.prototype.repeat = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 1);
+    expect(result.details.items[0].signals).toEqual(['String.prototype.repeat']);
+  });
+
+  it('fails code with multiple legacy polyfills', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'String.prototype.repeat = function() {}; String.prototype.includes = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 2);
+  });
+
+  it('counts multiple of the same polyfill from the same script only once', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: (() => {
+          // eslint-disable-next-line no-extend-native
+          String.prototype.repeat = function() {};
+          // eslint-disable-next-line no-extend-native
+          Object.defineProperty(String.prototype, 'repeat', function() {});
+        }),
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 1);
+  });
+
+  it('should identify polyfills in multiple patterns', async () => {
+    const codeSnippets = [
+      'String.prototype.repeat = function() {}',
+      'String.prototype["repeat"] = function() {}',
+      'String.prototype[\'repeat\'] = function() {}',
+      'Object.defineProperty(String.prototype, "repeat", function() {})',
+      'Object.defineProperty(String.prototype, \'repeat\', function() {})',
+      'Object.defineProperty(window, \'WeakMap\', function() {})',
+      '$export($export.S,"Object",{values:function values(t){return i(t)}})',
+      'WeakMap = function() {}',
+      'window.WeakMap = function() {}',
+      'function WeakMap() {}',
+      'String.raw = function() {}',
+    ];
+    const variants = createVariants(codeSnippets);
+    const scripts = variants.map((code, i) => {
+      return {code, url: `https://www.example.com/${i}.js`};
+    });
+    const getCodeForUrl = url => scripts.find(script => script.url === url).code;
+    const artifacts = createArtifacts(scripts);
+
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    expect(result.details.items.map(item => getCodeForUrl(item.url)))
+      .toEqual(scripts.map(script => getCodeForUrl(script.url)));
+    assert.equal(result.score, 0);
+  });
+
+  it('should not misidentify legacy code', async () => {
+    const codeSnippets = [
+      'i.prototype.toArrayBuffer = blah',
+      'this.childListChangeMap=void 0',
+      't.toPromise=u,t.makePromise=u,t.fromPromise=function(e){return new o.default',
+      'var n=new Error(h.apply(void 0,[d].concat(f)));n.name="Invariant Violation";',
+      'var b=typeof Map==="function"?new Map():void 0',
+      'd.Promise=s;var y,g,v,b=function(n,o,t){if(function(t){if("function"!=typeof t)th',
+    ];
+    const variants = createVariants(codeSnippets);
+    const scripts = variants.map((code, i) => {
+      return {code, url: `https://www.example.com/${i}.js`};
+    });
+    const getCodeForUrl = url => scripts.find(script => script.url === url).code;
+    const artifacts = createArtifacts(scripts);
+
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    expect(result.details.items.map(item => getCodeForUrl(item.url))).toEqual([]);
+    assert.equal(result.score, 1);
+  });
+});
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index ca9c7a352..98f0aec30 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -147,6 +147,76 @@ describe('Config', () => {
     })).toThrow('VRMLElements gatherer, required by audit missing-artifact-audit, was not found in config');
   });
 
+  // eslint-disable-next-line max-len
+  it('does not throw when an audit requests an optional artifact with no gatherer supplying it', async () => {
+    class DoesntNeedYourCrap extends Audit {
+      static get meta() {
+        return {
+          id: 'optional-artifact-audit',
+          title: 'none',
+          description: 'none',
+          requiredArtifacts: [
+            'URL', // base artifact
+            'ViewportDimensions', // from gatherer
+          ],
+          __internalOptionalArtifacts: [
+            'SourceMaps', // Not in the config.
+          ],
+        };
+      }
+
+      static audit() {}
+    }
+
+    // Shouldn't throw.
+    const config = new Config({
+      extends: 'lighthouse:default',
+      audits: [DoesntNeedYourCrap],
+    }, {
+      // Trigger filtering logic.
+      onlyAudits: ['optional-artifact-audit'],
+    });
+    expect(config.passes[0].gatherers.map(g => g.path)).toEqual(['viewport-dimensions']);
+  });
+
+  it('should keep optional artifacts in gatherers after filter', async () => {
+    class ButWillStillTakeYourCrap extends Audit {
+      static get meta() {
+        return {
+          id: 'optional-artifact-audit',
+          title: 'none',
+          description: 'none',
+          requiredArtifacts: [
+            'URL', // base artifact
+            'ViewportDimensions', // from gatherer
+          ],
+          __internalOptionalArtifacts: [
+            'SourceMaps', // Is in the config.
+          ],
+        };
+      }
+
+      static audit() {}
+    }
+
+    const config = new Config({
+      extends: 'lighthouse:default',
+      // TODO(cjamcl): remove when source-maps is in default config.
+      passes: [{
+        passName: 'defaultPass',
+        gatherers: [
+          'source-maps',
+        ],
+      }],
+      audits: [ButWillStillTakeYourCrap],
+    }, {
+      // Trigger filtering logic.
+      onlyAudits: ['optional-artifact-audit'],
+    });
+    expect(config.passes[0].gatherers.map(g => g.path))
+      .toEqual(['viewport-dimensions', 'source-maps']);
+  });
+
   it('does not throw when an audit requires only base artifacts', () => {
     class BaseArtifactsAudit extends Audit {
       static get meta() {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 625d696a0..12cafeff6 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2836,6 +2836,13 @@
       "score": 1,
       "scoreDisplayMode": "binary"
     },
+    "charset": {
+      "id": "charset",
+      "title": "Charset declaration is missing or occurs too late in the HTML",
+      "description": "A character encoding declaration is required. It can be done with a <meta> tagin the first 1024 bytes of the HTML or in the Content-Type HTTP response header. [Learn more](https://www.w3.org/International/questions/qa-html-encoding-declarations).",
+      "score": 0,
+      "scoreDisplayMode": "binary"
+    },
     "dom-size": {
       "id": "dom-size",
       "title": "Avoids an excessive DOM size",
@@ -4112,6 +4119,10 @@
           "id": "doctype",
           "weight": 1
         },
+        {
+          "id": "charset",
+          "weight": 1
+        },
         {
           "id": "no-vulnerable-libraries",
           "weight": 1
@@ -5311,6 +5322,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:charset",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:dom-size",
@@ -6455,6 +6472,12 @@
       "lighthouse-core/audits/dobetterweb/doctype.js | description": [
         "audits.doctype.description"
       ],
+      "lighthouse-core/audits/dobetterweb/charset.js | failureTitle": [
+        "audits.charset.title"
+      ],
+      "lighthouse-core/audits/dobetterweb/charset.js | description": [
+        "audits.charset.description"
+      ],
       "lighthouse-core/audits/dobetterweb/dom-size.js | title": [
         "audits[dom-size].title"
       ],
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 99c41c861..2134cf506 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -366,7 +366,7 @@ describe('Runner', () => {
       rimraf.sync(resolvedPath);
     });
 
-    it('only passes the required artifacts to the audit', async () => {
+    it('only passes the requested artifacts to the audit (no optional artifacts)', async () => {
       class SimpleAudit extends Audit {
         static get meta() {
           return {
@@ -397,6 +397,40 @@ describe('Runner', () => {
         ArtifactC: 'cherry',
       });
     });
+
+    it('only passes the requested artifacts to the audit (w/ optional artifacts)', async () => {
+      class SimpleAudit extends Audit {
+        static get meta() {
+          return {
+            id: 'simple',
+            title: 'Requires some artifacts',
+            failureTitle: 'Artifacts',
+            description: 'Test for always throwing',
+            requiredArtifacts: ['ArtifactA', 'ArtifactC'],
+            __internalOptionalArtifacts: ['ArtifactD'],
+          };
+        }
+      }
+
+      const auditMockFn = SimpleAudit.audit = jest.fn().mockReturnValue({score: 1});
+      const config = new Config({
+        settings: {
+          auditMode: __dirname + '/fixtures/artifacts/alphabet-artifacts/',
+        },
+        audits: [
+          SimpleAudit,
+        ],
+      });
+
+      const results = await Runner.run({}, {config});
+      expect(results.lhr).toMatchObject({audits: {simple: {score: 1}}});
+      expect(auditMockFn).toHaveBeenCalled();
+      expect(auditMockFn.mock.calls[0][0]).toEqual({
+        ArtifactA: 'apple',
+        ArtifactC: 'cherry',
+        ArtifactD: 'date',
+      });
+    });
   });
 
   describe('Bad audit behavior handling', () => {