Repository: GoogleChrome/lighthouse
Pull request #9872 (2019-10-21 22:02:02)

tests(static-server): allow hook to modify response body
This hook is needed to modify the URLs in the fixtures for running smoke tests on Lightrider in CI.

Googlers: see cl/275909869 / [doc](https://docs.google.com/document/d/1oZzphmzd6mdsbjT1TZNzA0OMXFjr90EfPQz6CffHMvs/edit)

patrickhulce: I'm going to dismiss my review request @brendankenny this is all you since it's a large change to be unable to see the context for :)
brendankenny: > I'm going to dismiss my review request @brendankenny this is all you since it's a large change to be unable to see the context for :)

`string` -> `string` transformations (e.g. replace on a regex) could make it so even innocuous changes break things (let alone future refactors). It would be great to get an idea of the decision/constraint space so we could evaluate if there are other alternatives.
brendankenny: > so we could evaluate if there are other alternatives

(or just what to document so future folks know which live wires not to touch)
connorjclark: This is just meant to change the port numbers on URLs, so the hook could be less generic and be specifically `replacePort(original, new)` or sumthin.

We can follow up on this after I write / we discuss a doc re: the whole integration testing story.
connorjclark: The first post was updated with the relevant doc (@patrickhulce, nothing has changed since you last reviewed the doc)

I don't think knowing the usage internally is necessary for reviewing this change. The idea is just that the ports must be configurable (the internal test is assigned 2 free ports, and they are random). `replacePort` is the stricter way to do it, the current PR is the most generic way to do it. I don't see this static test server being consumed by _anyone_ but ourselves, so perhaps the generic solution is fine?

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/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/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/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/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index b8c437e5f..ea1a908df 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -788,6 +788,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..8e2e51e17 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -788,6 +788,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/scripts/git3po-rules/pr-assignee.yaml b/lighthouse-core/scripts/git3po-rules/pr-assignee.yaml
new file mode 100644
index 000000000..6990f0a38
--- /dev/null
+++ b/lighthouse-core/scripts/git3po-rules/pr-assignee.yaml
@@ -0,0 +1,22 @@
+# Assign the first requested reviewer
+repo: GoogleChrome/lighthouse
+pullRequests: true
+filters:
+  - type: issue
+    criteria:
+      state: open
+      createdAt:
+        # we started the single-reviewer policy around 2/1/2020
+        $gt: '%%date(feb 1 2020)%%'
+      'assignees.length':
+        $eq: 0
+      'reviewers.length':
+        $eq: 1
+  - type: label
+    label: chillin ❄
+    negate: true
+actions:
+  - type: add_assignee
+    login: '%%reviewer%%'
+  - type: add_label
+    label: waiting4reviewer
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/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 00d9bfc3e..e0463d98b 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -56,4 +56,5 @@ echo -e "$check Locale JSON files copied."
 
 echo ""
 echo "Done. To rebase the test expectations, run: "
-echo "    yarn --cwd ~/chromium/src/third_party/blink/renderer/devtools test 'http/tests/devtools/audits/*.js' --reset-results"
+echo "    yarn --cwd ~/chromium/src/third_party/devtools-frontend/src test 'http/tests/devtools/lighthouse/*.js' --reset-results"
+echo " (you also need to do `autoninja -C out/Linux chrome blink_tests` in the chromium checkout)"
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..608fc9b05 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,9 @@
     "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-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-lr": "node ./build/build-lightrider-bundles.js",
     "build-pack": "bash build/build-pack.sh",
@@ -30,6 +32,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 +182,7 @@
   },
   "bundlesize": [
     {
-      "path": "./dist/extension/scripts/popup-bundle.js",
+      "path": "./dist/extension-chrome/scripts/popup-bundle.js",
       "maxSize": "15 kB"
     },
     {
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/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;
         /**


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/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/frontends/smokehouse-bin.js b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
index 002e1355d..ce95564a9 100644
--- a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
+++ b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
@@ -109,8 +109,8 @@ async function begin() {
     serverForOffline.listen(10503, 'localhost');
     isPassing = await runSmokehouse(testDefns, options);
   } 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/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/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/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);
+  });
+});