Repository: GoogleChrome/lighthouse
Pull request #12723 (2021-06-29 23:40:40)

i18n: allow strings with duplicate message and descriptions
I started looking into fatal `collect-strings` collisions after https://github.com/GoogleChrome/lighthouse/pull/12697/files#r658831618 when it became clear that that `lighthouse-core/audits/accessibility/aria-progressbar-name.js | description` has been accidentally colliding with `lighthouse-core/audits/accessibility/aria-treeitem-name.js | description` for quite some time and we (and TC) haven't noticed.

It seems like something has changed with collisions. At least from what I can understand from inspecting some of the intermediate files in the pipeline, internally those two strings got deduped into a single message for translation (they share an ID which is hashed from the `message` and `meaning`), then properly duplicated again when dumped into our LHL format. Basically exactly what you'd hope for. From docs I've looked at now, it's actually encouraged that we allow colliding strings and only add a `meaning` if we _really_ want the strings to be translated separately, so it's possible this was a bug in the handler that ingested our CTC format that was incidentally fixed in the last few years.

To test this, I took four currently colliding accessibility messages and made their descriptions the same as well so they'd be full collisions. They appear to have gone through a full TC roundtrip once, but I'm running again just to be sure :)

If this all looks good, we can probably follow up with setting a lot of the current collision list be full duplicates (no reason each stack pack needs "WordPress"/"Drupal"/"Joomla" in their descriptions when the string is just about using "HTML5 video"). Sometimes an explicit approach like @adamraine's in https://github.com/GoogleChrome/lighthouse/pull/12714#discussion_r660887254 will make more sense when a lot of strings are explicitly being shared between files, but it's also nice to know that strings that collide by happenstance are no big deal.

brendankenny: Second extract/dump was successful, so I think we're good here. I do have a lingering fear I'm missing something (some placeholder business or something? Feels like that should be handled with `@example`, though...)

Patch:
diff --git a/.github/workflows/devtools.yml b/.github/workflows/devtools.yml
index 8b277623b..dcce705d0 100644
--- a/.github/workflows/devtools.yml
+++ b/.github/workflows/devtools.yml
@@ -42,6 +42,8 @@ jobs:
 
     - run: yarn --frozen-lockfile
       working-directory: ${{ github.workspace }}/lighthouse
+    - run: yarn build-report
+      working-directory: ${{ github.workspace }}/lighthouse
     - run: yarn build-devtools
       working-directory: ${{ github.workspace }}/lighthouse
 
diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
index f7bf93707..f237ea300 100644
--- a/.github/workflows/smoke.yml
+++ b/.github/workflows/smoke.yml
@@ -46,6 +46,7 @@ jobs:
       run: bash $GITHUB_WORKSPACE/lighthouse-core/scripts/download-chrome.sh && mv chrome-linux chrome-linux-tot
 
     - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
 
     - run: sudo apt-get install xvfb
     - name: Run smoke tests
@@ -78,6 +79,7 @@ jobs:
         node-version: 12.x
 
     - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
 
     - name: Run smoke tests
       # Windows bots are slow, so only run enough tests to verify matching behavior.
@@ -100,6 +102,7 @@ jobs:
         node-version: 12.x
 
     - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
     - run: yarn build-devtools
 
     - run: sudo apt-get install xvfb
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index 498b7aaf2..5d922d89f 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -44,6 +44,7 @@ jobs:
         pip install protobuf==3.7.1
 
     - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
 
     - run: yarn test-proto # Run before unit-core because the roundtrip json is needed for proto tests.
 
@@ -81,6 +82,7 @@ jobs:
         node-version: 12.x
 
     - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
 
     - name: yarn unit-cli
       run: yarn unit-cli
diff --git a/.gitignore b/.gitignore
index 25096b003..d1d204a1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,8 @@ last-run-results.html
 *.artifacts.log
 *.ctc.json
 
+report/generated
+
 !lighthouse-core/test/results/artifacts/*.trace.json
 !lighthouse-core/test/results/artifacts/*.devtoolslog.json
 !lighthouse-core/test/fixtures/artifacts/**/*.trace.json
diff --git a/.npmignore b/.npmignore
index ef71c7eed..f47431799 100644
--- a/.npmignore
+++ b/.npmignore
@@ -53,6 +53,9 @@ yarn-error.log
 results.html
 *.lcov
 
+# generated files needed for publish
+!dist/report/standalone.js
+
 # large files
 changelog.md
 
diff --git a/build/build-bundle.js b/build/build-bundle.js
index 7e77ac53b..c29375081 100644
--- a/build/build-bundle.js
+++ b/build/build-bundle.js
@@ -14,12 +14,12 @@ const fs = require('fs');
 const path = require('path');
 const assert = require('assert').strict;
 const mkdir = fs.promises.mkdir;
-
 const LighthouseRunner = require('../lighthouse-core/runner.js');
 const exorcist = require('exorcist');
 const browserify = require('browserify');
 const terser = require('terser');
 const {minifyFileTransform} = require('./build-utils.js');
+const {LH_ROOT} = require('../root.js');
 
 const COMMIT_HASH = require('child_process')
   .execSync('git rev-parse HEAD')
@@ -31,7 +31,7 @@ const audits = LighthouseRunner.getAuditList()
 const gatherers = LighthouseRunner.getGathererList()
     .map(f => './lighthouse-core/gather/gatherers/' + f.replace(/\.js$/, ''));
 
-const locales = fs.readdirSync(__dirname + '/../lighthouse-core/lib/i18n/locales/')
+const locales = fs.readdirSync(LH_ROOT + '/lighthouse-core/lib/i18n/locales/')
     .map(f => require.resolve(`../lighthouse-core/lib/i18n/locales/${f}`));
 
 // HACK: manually include the lighthouse-plugin-publisher-ads audits.
diff --git a/build/build-cdt-lib.js b/build/build-cdt-lib.js
index d04c9bf3c..8510fdbb8 100644
--- a/build/build-cdt-lib.js
+++ b/build/build-cdt-lib.js
@@ -9,8 +9,9 @@
 
 const fs = require('fs');
 const ts = require('typescript');
+const {LH_ROOT} = require('../root.js');
 
-const outDir = `${__dirname}/../lighthouse-core/lib/cdt/generated`;
+const outDir = `${LH_ROOT}/lighthouse-core/lib/cdt/generated`;
 const files = {
   'node_modules/chrome-devtools-frontend/front_end/sdk/SourceMap.js': 'SourceMap.js',
 };
diff --git a/build/build-dt-report-resources.js b/build/build-dt-report-resources.js
index 1f76abe22..6f3a97ddf 100644
--- a/build/build-dt-report-resources.js
+++ b/build/build-dt-report-resources.js
@@ -9,8 +9,9 @@ const browserify = require('browserify');
 const fs = require('fs');
 const path = require('path');
 const assert = require('assert').strict;
+const {LH_ROOT} = require('../root.js');
 
-const distDir = path.join(__dirname, '..', 'dist', 'dt-report-resources');
+const distDir = path.join(LH_ROOT, 'dist', 'dt-report-resources');
 const bundleOutFile = `${distDir}/report-generator.js`;
 const generatorFilename = `./report/report-generator.js`;
 const htmlReportAssets = require('../report/report-assets.js');
diff --git a/build/build-extension.js b/build/build-extension.js
index 0a24247e5..40c9f12c0 100644
--- a/build/build-extension.js
+++ b/build/build-extension.js
@@ -7,11 +7,11 @@
 
 const fs = require('fs');
 const mkdir = fs.promises.mkdir;
-
 const archiver = require('archiver');
 const cpy = require('cpy');
 const browserify = require('browserify');
 const path = require('path');
+const {LH_ROOT} = require('../root.js');
 
 const argv = process.argv.slice(2);
 const browserBrand = argv[0];
@@ -19,8 +19,8 @@ const browserBrand = argv[0];
 const sourceName = 'popup.js';
 const distName = 'popup-bundle.js';
 
-const sourceDir = `${__dirname}/../clients/extension`;
-const distDir = `${__dirname}/../dist/extension-${browserBrand}`;
+const sourceDir = `${LH_ROOT}/clients/extension`;
+const distDir = `${LH_ROOT}/dist/extension-${browserBrand}`;
 const packagePath = `${distDir}/../extension-${browserBrand}-package`;
 
 const manifestVersion = require(`${sourceDir}/manifest.json`).version;
diff --git a/build/build-lightrider-bundles.js b/build/build-lightrider-bundles.js
index dff72b4b4..88d376a81 100644
--- a/build/build-lightrider-bundles.js
+++ b/build/build-lightrider-bundles.js
@@ -10,9 +10,10 @@ const fs = require('fs');
 const path = require('path');
 const bundleBuilder = require('./build-bundle.js');
 const {minifyFileTransform} = require('./build-utils.js');
+const {LH_ROOT} = require('../root.js');
 
-const distDir = path.join(__dirname, '..', 'dist', 'lightrider');
-const sourceDir = __dirname + '/../clients/lightrider';
+const distDir = path.join(LH_ROOT, 'dist', 'lightrider');
+const sourceDir = path.join(LH_ROOT, 'clients', 'lightrider');
 
 const bundleOutFile = `${distDir}/report-generator-bundle.js`;
 const generatorFilename = `./report/report-generator.js`;
diff --git a/build/build-report.js b/build/build-report.js
new file mode 100644
index 000000000..457c60128
--- /dev/null
+++ b/build/build-report.js
@@ -0,0 +1,46 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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');
+
+function concatRendererCode() {
+  return [
+    fs.readFileSync(__dirname + '/../report/renderer/util.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/dom.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/details-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/crc-details-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/snippet-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/element-screenshot-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../lighthouse-core/lib/file-namer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/logger.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/report-ui-features.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/category-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/performance-category-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/pwa-category-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/report-renderer.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/i18n.js', 'utf8'),
+    fs.readFileSync(__dirname + '/../report/renderer/text-encoding.js', 'utf8'),
+  ].join(';\n');
+}
+
+async function buildStandaloneReport() {
+  const REPORT_JAVASCRIPT = [
+    concatRendererCode(),
+    fs.readFileSync(__dirname + '/../report/clients/standalone.js', 'utf8'),
+  ].join(';\n');
+  fs.mkdirSync(__dirname + '/../dist/report', {recursive: true});
+  fs.writeFileSync(__dirname + '/../dist/report/standalone.js', REPORT_JAVASCRIPT);
+}
+
+if (require.main === module) {
+  buildStandaloneReport();
+}
+
+module.exports = {
+  buildStandaloneReport,
+  concatRendererCode,
+};
diff --git a/build/build-smokehouse-bundle.js b/build/build-smokehouse-bundle.js
index bc7734913..97a56f0f7 100644
--- a/build/build-smokehouse-bundle.js
+++ b/build/build-smokehouse-bundle.js
@@ -7,8 +7,9 @@
 
 const browserify = require('browserify');
 const fs = require('fs');
+const {LH_ROOT} = require('../root.js');
 
-const distDir = `${__dirname}/../dist`;
+const distDir = `${LH_ROOT}/dist`;
 const bundleOutFile = `${distDir}/smokehouse-bundle.js`;
 const smokehouseLibFilename = './lighthouse-cli/test/smokehouse/frontends/lib.js';
 
diff --git a/build/build-treemap.js b/build/build-treemap.js
index 3260a69e7..a7e311ae5 100644
--- a/build/build-treemap.js
+++ b/build/build-treemap.js
@@ -9,6 +9,7 @@
 
 const fs = require('fs');
 const GhPagesApp = require('./gh-pages-app.js');
+const {LH_ROOT} = require('../root.js');
 
 /**
  * Extract only the strings needed for lighthouse-treemap into
@@ -46,7 +47,7 @@ function buildStrings() {
 async function run() {
   const app = new GhPagesApp({
     name: 'treemap',
-    appDir: `${__dirname}/../lighthouse-treemap/app`,
+    appDir: `${LH_ROOT}/lighthouse-treemap/app`,
     html: {path: 'index.html'},
     stylesheets: [
       fs.readFileSync(require.resolve('tabulator-tables/dist/css/tabulator.min.css'), 'utf8'),
diff --git a/build/build-viewer.js b/build/build-viewer.js
index 1c7956b7d..c75d5265f 100644
--- a/build/build-viewer.js
+++ b/build/build-viewer.js
@@ -9,14 +9,16 @@ const fs = require('fs');
 const browserify = require('browserify');
 const GhPagesApp = require('./gh-pages-app.js');
 const {minifyFileTransform} = require('./build-utils.js');
+const {concatRendererCode} = require('./build-report.js');
 const htmlReportAssets = require('../report/report-assets.js');
+const {LH_ROOT} = require('../root.js');
 
 /**
  * Build viewer, optionally deploying to gh-pages if `--deploy` flag was set.
  */
 async function run() {
   // JS bundle from browserified ReportGenerator.
-  const generatorFilename = `${__dirname}/../report/report-generator.js`;
+  const generatorFilename = `${LH_ROOT}/report/report-generator.js`;
   const generatorBrowserify = browserify(generatorFilename, {standalone: 'ReportGenerator'})
     .transform('@wardpeet/brfs', {
       readFileSyncTransform: minifyFileTransform,
@@ -32,7 +34,7 @@ async function run() {
 
   const app = new GhPagesApp({
     name: 'viewer',
-    appDir: `${__dirname}/../lighthouse-viewer/app`,
+    appDir: `${LH_ROOT}/lighthouse-viewer/app`,
     html: {path: 'index.html'},
     htmlReplacements: {
       '%%LIGHTHOUSE_TEMPLATES%%': htmlReportAssets.REPORT_TEMPLATES,
@@ -43,7 +45,7 @@ async function run() {
     ],
     javascripts: [
       await generatorJsPromise,
-      htmlReportAssets.REPORT_JAVASCRIPT,
+      concatRendererCode(),
       fs.readFileSync(require.resolve('idb-keyval/dist/idb-keyval-min.js'), 'utf8'),
       {path: 'src/*'},
     ],
diff --git a/build/changelog-generator/index.js b/build/changelog-generator/index.js
index efc2acd85..e3d79163e 100644
--- a/build/changelog-generator/index.js
+++ b/build/changelog-generator/index.js
@@ -4,6 +4,7 @@
  * 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 readFileSync = require('fs').readFileSync;
 const resolve = require('path').resolve;
 const mainTemplate = readFileSync(resolve(__dirname, 'templates/template.hbs')).toString();
diff --git a/build/gh-pages-app.js b/build/gh-pages-app.js
index d9bffeb4d..e9c5931a6 100644
--- a/build/gh-pages-app.js
+++ b/build/gh-pages-app.js
@@ -7,14 +7,14 @@
 
 const fs = require('fs');
 const path = require('path');
-
 const cpy = require('cpy');
 const ghPages = require('gh-pages');
 const glob = require('glob');
 const lighthousePackage = require('../package.json');
 const terser = require('terser');
+const {LH_ROOT} = require('../root.js');
 
-const ghPagesDistDir = `${__dirname}/../dist/gh-pages`;
+const ghPagesDistDir = `${LH_ROOT}/dist/gh-pages`;
 
 const license = `/*
 * @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
diff --git a/build/readme.md b/build/readme.md
index c8900c9c6..164f423e2 100644
--- a/build/readme.md
+++ b/build/readme.md
@@ -15,7 +15,7 @@ Additionally, there are build processes for:
 To build the devtools files and roll them into a local checkout of Chromium:
 
 ```sh
-yarn build-devtools && yarn devtools
+yarn devtools
 ```
 
 
diff --git a/docs/releasing.md b/docs/releasing.md
index 3f4578516..87d6c7f32 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -154,7 +154,6 @@ echo "Complete the _Release publicity_ tasks documented above"
 
 ```sh
 git checkout vx.x.x # Checkout the specific version.
-yarn build-devtools
 yarn devtools ~/src/devtools/devtools-frontend
 
 cd ~/src/devtools/devtools-frontend
diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js
index 7fd0c2b71..31f16805d 100644
--- a/lighthouse-cli/bin.js
+++ b/lighthouse-cli/bin.js
@@ -5,11 +5,8 @@
  */
 'use strict';
 
-const fs = require('fs');
-const path = require('path');
-
-/*
- * The relationship between these CLI modules:
+/**
+ * @fileoverview The relationship between these CLI modules:
  *
  *   index.js     : only calls bin.js's begin()
  *   cli-flags.js : leverages yargs to read argv, outputs LH.CliFlags
@@ -21,24 +18,25 @@ const path = require('path');
  *               cli-flags        lh-core/index
  */
 
+const fs = require('fs');
+const path = require('path');
 const commands = require('./commands/commands.js');
 const printer = require('./printer.js');
 const {getFlags} = require('./cli-flags.js');
 const {runLighthouse} = require('./run.js');
 const {generateConfig} = require('../lighthouse-core/index.js');
-
 const log = require('lighthouse-logger');
 const pkg = require('../package.json');
 const Sentry = require('../lighthouse-core/lib/sentry.js');
-
 const updateNotifier = require('update-notifier');
 const {askPermission} = require('./sentry-prompt.js');
+const {LH_ROOT} = require('../root.js');
 
 /**
  * @return {boolean}
  */
 function isDev() {
-  return fs.existsSync(path.join(__dirname, '../.git'));
+  return fs.existsSync(path.join(LH_ROOT, '/.git'));
 }
 
 /**
diff --git a/lighthouse-core/audits/accessibility/aria-command-name.js b/lighthouse-core/audits/accessibility/aria-command-name.js
index 94add850d..b410e85b0 100644
--- a/lighthouse-core/audits/accessibility/aria-command-name.js
+++ b/lighthouse-core/audits/accessibility/aria-command-name.js
@@ -18,7 +18,7 @@ const UIStrings = {
   title: '`button`, `link`, and `menuitem` elements have accessible names',
   /** Title of an accessibility audit that evaluates if important HTML elements do not have accessible names. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed. */
   failureTitle: '`button`, `link`, and `menuitem` elements do not have accessible names.',
-  /** Description of a Lighthouse audit that tells the user *why* they should have accessible names for command elements. 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 of a Lighthouse audit that tells the user *why* they should have accessible names for HTML elements. 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: 'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/).',
 };
 
diff --git a/lighthouse-core/audits/accessibility/aria-meter-name.js b/lighthouse-core/audits/accessibility/aria-meter-name.js
index 35c8ad53d..c7c0e98e3 100644
--- a/lighthouse-core/audits/accessibility/aria-meter-name.js
+++ b/lighthouse-core/audits/accessibility/aria-meter-name.js
@@ -18,7 +18,7 @@ const UIStrings = {
   title: 'ARIA `meter` elements have accessible names',
   /** Title of an accessibility audit that evaluates if meter HTML elements do not have accessible names. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed. */
   failureTitle: 'ARIA `meter` elements do not have accessible names.',
-  /** Description of a Lighthouse audit that tells the user *why* they should have accessible names for meter elements. 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 of a Lighthouse audit that tells the user *why* they should have accessible names for HTML elements. 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: 'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/).',
 };
 
diff --git a/lighthouse-core/audits/accessibility/aria-tooltip-name.js b/lighthouse-core/audits/accessibility/aria-tooltip-name.js
index 2d0284e24..f1ef06d07 100644
--- a/lighthouse-core/audits/accessibility/aria-tooltip-name.js
+++ b/lighthouse-core/audits/accessibility/aria-tooltip-name.js
@@ -18,7 +18,7 @@ const UIStrings = {
   title: 'ARIA `tooltip` elements have accessible names',
   /** Title of an accessibility audit that evaluates if tooltip HTML elements do not have accessible names. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed. */
   failureTitle: 'ARIA `tooltip` elements do not have accessible names.',
-  /** Description of a Lighthouse audit that tells the user *why* they should have accessible names for tooltips. 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 of a Lighthouse audit that tells the user *why* they should have accessible names for HTML elements. 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: 'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/).',
 };
 
diff --git a/lighthouse-core/audits/accessibility/aria-treeitem-name.js b/lighthouse-core/audits/accessibility/aria-treeitem-name.js
index cd382aa61..a6ce498ce 100644
--- a/lighthouse-core/audits/accessibility/aria-treeitem-name.js
+++ b/lighthouse-core/audits/accessibility/aria-treeitem-name.js
@@ -18,7 +18,7 @@ const UIStrings = {
   title: 'ARIA `treeitem` elements have accessible names',
   /** Title of an accessibility audit that evaluates if treeitem HTML elements do not have accessible names. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed. */
   failureTitle: 'ARIA `treeitem` elements do not have accessible names.',
-  /** Description of a Lighthouse audit that tells the user *why* they should try to pass. 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 of a Lighthouse audit that tells the user *why* they should have accessible names for HTML elements. 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: 'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/).',
 };
 
diff --git a/lighthouse-core/audits/byte-efficiency/modern-image-formats.js b/lighthouse-core/audits/byte-efficiency/modern-image-formats.js
index 926adf540..770407efc 100644
--- a/lighthouse-core/audits/byte-efficiency/modern-image-formats.js
+++ b/lighthouse-core/audits/byte-efficiency/modern-image-formats.js
@@ -16,7 +16,7 @@ const UIStrings = {
   /** Imperative title of a Lighthouse audit that tells the user to serve images in newer and more efficient image formats in order to enhance the performance of a page. A non-modern image format was designed 20+ years ago. This is displayed in a list of audit titles that Lighthouse generates. */
   title: 'Serve images in next-gen formats',
   /** Description of a Lighthouse audit that tells the user *why* they should use newer and more efficient image formats. 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: 'Image formats like JPEG 2000, JPEG XR, and WebP often provide better ' +
+  description: 'Image formats like WebP and AVIF often provide better ' +
     'compression than PNG or JPEG, which means faster downloads and less data consumption. ' +
     '[Learn more](https://web.dev/uses-webp-images/).',
 };
@@ -40,16 +40,6 @@ class ModernImageFormats extends ByteEfficiencyAudit {
     };
   }
 
-  /**
-   * @param {{originalSize: number, webpSize: number}} image
-   * @return {{bytes: number, percent: number}}
-   */
-  static computeSavings(image) {
-    const bytes = image.originalSize - image.webpSize;
-    const percent = 100 * bytes / image.originalSize;
-    return {bytes, percent};
-  }
-
   /**
    * @param {{naturalWidth: number, naturalHeight: number}} imageElement
    * @return {number}
@@ -65,6 +55,38 @@ class ModernImageFormats extends ByteEfficiencyAudit {
     return Math.round(totalPixels * expectedBytesPerPixel);
   }
 
+  /**
+   * @param {{naturalWidth: number, naturalHeight: number}} imageElement
+   * @return {number}
+   */
+  static estimateAvifSizeFromDimensions(imageElement) {
+    const totalPixels = imageElement.naturalWidth * imageElement.naturalHeight;
+    // See above for the rationale behind our 2 byte-per-pixel baseline and WebP ratio of 10:1.
+    // AVIF usually gives ~20% additional savings on top of that, so we will use 12:1.
+    // This is quite pessimistic as Netflix study shows a photographic compression ratio of ~40:1
+    // (0.4 *bits* per pixel at SSIM 0.97).
+    // https://netflixtechblog.com/avif-for-next-generation-image-coding-b1d75675fe4
+    const expectedBytesPerPixel = 2 * 1 / 12;
+    return Math.round(totalPixels * expectedBytesPerPixel);
+  }
+
+  /**
+   * @param {{jpegSize: number | undefined, webpSize: number | undefined}} otherFormatSizes
+   * @return {number|undefined}
+   */
+  static estimateAvifSizeFromWebPAndJpegEstimates(otherFormatSizes) {
+    if (!otherFormatSizes.jpegSize || !otherFormatSizes.webpSize) return undefined;
+
+    // AVIF saves at least ~50% on JPEG, ~20% on WebP at low quality.
+    // http://downloads.aomedia.org/assets/pdf/symposium-2019/slides/CyrilConcolato_Netflix-AVIF-AOM-Research-Symposium-2019.pdf
+    // https://jakearchibald.com/2020/avif-has-landed/
+    // https://www.finally.agency/blog/what-is-avif-image-format
+    // See https://github.com/GoogleChrome/lighthouse/issues/12295#issue-840261460 for more.
+    const estimateFromJpeg = otherFormatSizes.jpegSize * 5 / 10;
+    const estimateFromWebp = otherFormatSizes.webpSize * 8 / 10;
+    return estimateFromJpeg / 2 + estimateFromWebp / 2;
+  }
+
   /**
    * @param {LH.Artifacts} artifacts
    * @return {ByteEfficiencyAudit.ByteEfficiencyProduct}
@@ -86,7 +108,15 @@ class ModernImageFormats extends ByteEfficiencyAudit {
         continue;
       }
 
+      // Skip if the image was already using a modern format.
+      if (image.mimeType === 'image/webp' || image.mimeType === 'image/avif') continue;
+
+      const jpegSize = image.jpegSize;
       let webpSize = image.webpSize;
+      let avifSize = ModernImageFormats.estimateAvifSizeFromWebPAndJpegEstimates({
+        jpegSize,
+        webpSize,
+      });
       let fromProtocol = true;
 
       if (typeof webpSize === 'undefined') {
@@ -107,21 +137,30 @@ class ModernImageFormats extends ByteEfficiencyAudit {
           naturalHeight,
           naturalWidth,
         });
+        avifSize = ModernImageFormats.estimateAvifSizeFromDimensions({
+          naturalHeight,
+          naturalWidth,
+        });
         fromProtocol = false;
       }
 
-      if (image.originalSize < webpSize + IGNORE_THRESHOLD_IN_BYTES) continue;
+      if (webpSize === undefined || avifSize === undefined) continue;
+
+      // Visible wasted bytes uses AVIF, but we still include the WebP savings in the LHR.
+      const wastedWebpBytes = image.originalSize - webpSize;
+      const wastedBytes = image.originalSize - avifSize;
+      if (wastedBytes < IGNORE_THRESHOLD_IN_BYTES) continue;
 
       const url = URL.elideDataURI(image.url);
       const isCrossOrigin = !URL.originsMatch(pageURL, image.url);
-      const webpSavings = ModernImageFormats.computeSavings({...image, webpSize: webpSize});
 
       items.push({
         url,
         fromProtocol,
         isCrossOrigin,
         totalBytes: image.originalSize,
-        wastedBytes: webpSavings.bytes,
+        wastedBytes,
+        wastedWebpBytes,
       });
     }
 
diff --git a/lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js
new file mode 100644
index 000000000..ed255ff87
--- /dev/null
+++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js
@@ -0,0 +1,94 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+/**
+ * @fileoverview Checks to see if the images used on the page are larger than
+ *   their display sizes. The audit will list all images that are larger than
+ *   their display size with DPR (a 1000px wide image displayed as a
+ *   500px high-res image on a Retina display is 100% used);
+ */
+'use strict';
+
+const Audit = require('../audit.js');
+const UsesResponsiveImages = require('./uses-responsive-images.js');
+const URL = require('../../lib/url-shim.js');
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Label for a column in a data table; entries will be the dimensions of an image as it appears on the page. */
+  columnDisplayedDimensions: 'Displayed dimensions',
+  /** Label for a column in a data table; entries will be the dimensions of an image from it's source file. */
+  columnActualDimensions: 'Actual dimensions',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+// Based on byte threshold of 4096, with 3 bytes per pixel.
+const IGNORE_THRESHOLD_IN_PIXELS = 1365;
+
+class UsesResponsiveImagesSnapshot extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'uses-responsive-images-snapshot',
+      title: UsesResponsiveImages.str_(UsesResponsiveImages.UIStrings.title),
+      description: UsesResponsiveImages.str_(UsesResponsiveImages.UIStrings.description),
+      supportedModes: ['snapshot'],
+      requiredArtifacts: ['ImageElements', 'ViewportDimensions'],
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @return {Promise<LH.Audit.Product>}
+   */
+  static async audit(artifacts) {
+    let score = 1;
+    /** @type {LH.Audit.Details.TableItem[]} */
+    const items = [];
+    for (const image of artifacts.ImageElements) {
+      if (!image.naturalDimensions) continue;
+      const actual = image.naturalDimensions;
+      const displayed = UsesResponsiveImages.getDisplayedDimensions(
+        {...image, naturalWidth: actual.width, naturalHeight: actual.height},
+        artifacts.ViewportDimensions
+      );
+
+      const actualPixels = actual.width * actual.height;
+      const usedPixels = displayed.width * displayed.height;
+
+      if (actualPixels <= usedPixels) continue;
+      if (actualPixels - usedPixels > IGNORE_THRESHOLD_IN_PIXELS) score = 0;
+
+      items.push({
+        url: URL.elideDataURI(image.src),
+        displayedDimensions: `${displayed.width}x${displayed.height}`,
+        actualDimensions: `${actual.width}x${actual.height}`,
+      });
+    }
+
+    /** @type {LH.Audit.Details.Table['headings']} */
+    const headings = [
+      /* eslint-disable max-len */
+      {key: 'url', itemType: 'thumbnail', text: ''},
+      {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
+      {key: 'displayedDimensions', itemType: 'text', text: str_(UIStrings.columnDisplayedDimensions)},
+      {key: 'actualDimensions', itemType: 'text', text: str_(UIStrings.columnActualDimensions)},
+      /* eslint-enable max-len */
+    ];
+
+    const details = Audit.makeTableDetails(headings, items);
+
+    return {
+      score,
+      details,
+    };
+  }
+}
+
+module.exports = UsesResponsiveImagesSnapshot;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
index 14f599396..1287df4f4 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
@@ -47,6 +47,39 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
     };
   }
 
+  /**
+   * @param {LH.Artifacts.ImageElement & {naturalWidth: number, naturalHeight: number}} image
+   * @param {LH.Artifacts.ViewportDimensions} ViewportDimensions
+   * @return {{width: number, height: number}};
+   */
+  static getDisplayedDimensions(image, ViewportDimensions) {
+    if (image.displayedWidth && image.displayedHeight) {
+      return {
+        width: image.displayedWidth * ViewportDimensions.devicePixelRatio,
+        height: image.displayedHeight * ViewportDimensions.devicePixelRatio,
+      };
+    }
+
+    // If the image has 0 dimensions, it's probably hidden/offscreen, so we'll be as forgiving as possible
+    // and assume it's the size of two viewports. See https://github.com/GoogleChrome/lighthouse/issues/7236
+    const viewportWidth = ViewportDimensions.innerWidth;
+    const viewportHeight = ViewportDimensions.innerHeight * 2;
+    const imageAspectRatio = image.naturalWidth / image.naturalHeight;
+    const viewportAspectRatio = viewportWidth / viewportHeight;
+    let usedViewportWidth = viewportWidth;
+    let usedViewportHeight = viewportHeight;
+    if (imageAspectRatio > viewportAspectRatio) {
+      usedViewportHeight = viewportWidth / imageAspectRatio;
+    } else {
+      usedViewportWidth = viewportHeight * imageAspectRatio;
+    }
+
+    return {
+      width: usedViewportWidth * ViewportDimensions.devicePixelRatio,
+      height: usedViewportHeight * ViewportDimensions.devicePixelRatio,
+    };
+  }
+
   /**
    * @param {LH.Artifacts.ImageElement & {naturalWidth: number, naturalHeight: number}} image
    * @param {LH.Artifacts.ViewportDimensions} ViewportDimensions
@@ -60,26 +93,8 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
       return null;
     }
 
-    let usedPixels = image.displayedWidth * image.displayedHeight *
-      Math.pow(ViewportDimensions.devicePixelRatio, 2);
-    // If the image has 0 dimensions, it's probably hidden/offscreen, so we'll be as forgiving as possible
-    // and assume it's the size of two viewports. See https://github.com/GoogleChrome/lighthouse/issues/7236
-    if (!usedPixels) {
-      const viewportWidth = ViewportDimensions.innerWidth;
-      const viewportHeight = ViewportDimensions.innerHeight * 2;
-      const imageAspectRatio = image.naturalWidth / image.naturalHeight;
-      const viewportAspectRatio = viewportWidth / viewportHeight;
-      let usedViewportWidth = viewportWidth;
-      let usedViewportHeight = viewportHeight;
-      if (imageAspectRatio > viewportAspectRatio) {
-        usedViewportHeight = viewportWidth / imageAspectRatio;
-      } else {
-        usedViewportWidth = viewportHeight * imageAspectRatio;
-      }
-
-      usedPixels = usedViewportWidth * usedViewportHeight *
-        Math.pow(ViewportDimensions.devicePixelRatio, 2);
-    }
+    const displayed = this.getDisplayedDimensions(image, ViewportDimensions);
+    const usedPixels = displayed.width * displayed.height;
 
     const url = URL.elideDataURI(image.src);
     const actualPixels = image.naturalWidth * image.naturalHeight;
@@ -163,3 +178,4 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
 
 module.exports = UsesResponsiveImages;
 module.exports.UIStrings = UIStrings;
+module.exports.str_ = str_;
diff --git a/lighthouse-core/fraggle-rock/config/default-config.js b/lighthouse-core/fraggle-rock/config/default-config.js
index 5ea13da23..3ba948990 100644
--- a/lighthouse-core/fraggle-rock/config/default-config.js
+++ b/lighthouse-core/fraggle-rock/config/default-config.js
@@ -7,6 +7,29 @@
 
 const legacyDefaultConfig = require('../../config/default-config.js');
 
+/** @type {LH.Config.AuditJson[]} */
+const frAudits = [
+  'byte-efficiency/uses-responsive-images-snapshot',
+];
+
+/** @type {Record<string, LH.Config.AuditRefJson[]>} */
+const frCategoryAuditRefExtensions = {
+  'performance': [
+    {id: 'uses-responsive-images-snapshot', weight: 0, group: 'diagnostics'},
+  ],
+};
+
+/** @return {LH.Config.Json['categories']} */
+function mergeCategories() {
+  if (!legacyDefaultConfig.categories) return {};
+  const categories = legacyDefaultConfig.categories;
+  for (const key of Object.keys(frCategoryAuditRefExtensions)) {
+    if (!categories[key]) continue;
+    categories[key].auditRefs.push(...frCategoryAuditRefExtensions[key]);
+  }
+  return categories;
+}
+
 // Ensure all artifact IDs match the typedefs.
 /** @type {Record<keyof LH.FRArtifacts, string>} */
 const artifacts = {
@@ -168,8 +191,8 @@ const defaultConfig = {
     },
   ],
   settings: legacyDefaultConfig.settings,
-  audits: legacyDefaultConfig.audits,
-  categories: legacyDefaultConfig.categories,
+  audits: [...(legacyDefaultConfig.audits || []), ...frAudits],
+  categories: mergeCategories(),
   groups: legacyDefaultConfig.groups,
 };
 
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 86f3dd829..977d21a09 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -13,13 +13,13 @@ const lookupClosestLocale = require('lookup-closest-locale');
 const LOCALES = require('./locales.js');
 const {isObjectOfUnknownValues, isObjectOrArrayOfUnknownValues} = require('../type-verifiers.js');
 const log = require('lighthouse-logger');
+const {LH_ROOT} = require('../../../root.js');
 
 const DEFAULT_LOCALE = 'en';
 
 /** @typedef {import('intl-messageformat-parser').Element} MessageElement */
 /** @typedef {import('intl-messageformat-parser').ArgumentElement} ArgumentElement */
 
-const LH_ROOT = path.join(__dirname, '../../../');
 const MESSAGE_I18N_ID_REGEX = / | [^\s]+$/;
 
 const UIStrings = {
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 95b1c960f..7e022ca33 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -477,7 +477,7 @@
     "message": "Avoid serving legacy JavaScript to modern browsers"
   },
   "lighthouse-core/audits/byte-efficiency/modern-image-formats.js | description": {
-    "message": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images/)."
+    "message": "Image formats like WebP and AVIF often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images/)."
   },
   "lighthouse-core/audits/byte-efficiency/modern-image-formats.js | title": {
     "message": "Serve images in next-gen formats"
@@ -548,6 +548,12 @@
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | title": {
     "message": "Efficiently encode images"
   },
+  "lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js | columnActualDimensions": {
+    "message": "Actual dimensions"
+  },
+  "lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js | columnDisplayedDimensions": {
+    "message": "Displayed dimensions"
+  },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | description": {
     "message": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://web.dev/uses-responsive-images/)."
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 2c301c4be..7026b8626 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -477,7 +477,7 @@
     "message": "Âv́ôíd̂ śêŕv̂ín̂ǵ l̂éĝáĉý Ĵáv̂áŜćr̂íp̂t́ t̂ó m̂ód̂ér̂ń b̂ŕôẃŝér̂ś"
   },
   "lighthouse-core/audits/byte-efficiency/modern-image-formats.js | description": {
-    "message": "Îḿâǵê f́ôŕm̂át̂ś l̂ík̂é ĴṔÊǴ 2000, ĴṔÊǴ X̂Ŕ, âńd̂ Ẃêb́P̂ óf̂t́êń p̂ŕôv́îd́ê b́êt́t̂ér̂ ćôḿp̂ŕêśŝíôń t̂h́âń P̂ŃĜ ór̂ J́P̂ÉĜ, ẃĥíĉh́ m̂éâńŝ f́âśt̂ér̂ d́ôẃn̂ĺôád̂ś âńd̂ ĺêśŝ d́ât́â ćôńŝúm̂ṕt̂íôń. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-webp-images/)."
+    "message": "Îḿâǵê f́ôŕm̂át̂ś l̂ík̂é Ŵéb̂Ṕ âńd̂ ÁV̂ÍF̂ óf̂t́êń p̂ŕôv́îd́ê b́êt́t̂ér̂ ćôḿp̂ŕêśŝíôń t̂h́âń P̂ŃĜ ór̂ J́P̂ÉĜ, ẃĥíĉh́ m̂éâńŝ f́âśt̂ér̂ d́ôẃn̂ĺôád̂ś âńd̂ ĺêśŝ d́ât́â ćôńŝúm̂ṕt̂íôń. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-webp-images/)."
   },
   "lighthouse-core/audits/byte-efficiency/modern-image-formats.js | title": {
     "message": "Ŝér̂v́ê ím̂áĝéŝ ín̂ ńêx́t̂-ǵêń f̂ór̂ḿât́ŝ"
@@ -548,6 +548,12 @@
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | title": {
     "message": "Êf́f̂íĉíêńt̂ĺŷ én̂ćôd́ê ím̂áĝéŝ"
   },
+  "lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js | columnActualDimensions": {
+    "message": "Âćt̂úâĺ d̂ím̂én̂śîón̂ś"
+  },
+  "lighthouse-core/audits/byte-efficiency/uses-responsive-images-snapshot.js | columnDisplayedDimensions": {
+    "message": "D̂íŝṕl̂áŷéd̂ d́îḿêńŝíôńŝ"
+  },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | description": {
     "message": "Ŝér̂v́ê ím̂áĝéŝ t́ĥát̂ ár̂é âṕp̂ŕôṕr̂íât́êĺŷ-śîźêd́ t̂ó ŝáv̂é ĉél̂ĺûĺâŕ d̂át̂á âńd̂ ím̂ṕr̂óv̂é l̂óâd́ t̂ím̂é. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-responsive-images/)."
   },
diff --git a/lighthouse-core/scripts/build-report-for-autodeployment.js b/lighthouse-core/scripts/build-report-for-autodeployment.js
index cbceb215f..963c03e6d 100644
--- a/lighthouse-core/scripts/build-report-for-autodeployment.js
+++ b/lighthouse-core/scripts/build-report-for-autodeployment.js
@@ -11,13 +11,13 @@
 const fs = require('fs');
 const path = require('path');
 const swapLocale = require('../lib/i18n/swap-locale.js');
-
 const ReportGenerator = require('../../report/report-generator.js');
 const {defaultSettings} = require('../config/constants.js');
 const lighthouse = require('../index.js');
 const lhr = /** @type {LH.Result} */ (require('../../lighthouse-core/test/results/sample_v2.json'));
+const {LH_ROOT} = require('../../root.js');
 
-const DIST = path.join(__dirname, `../../dist/now`);
+const DIST = path.join(LH_ROOT, `dist/now`);
 
 (async function() {
   addPluginCategory(lhr);
diff --git a/lighthouse-core/scripts/compare-runs.js b/lighthouse-core/scripts/compare-runs.js
index 3d1115065..18c669f57 100644
--- a/lighthouse-core/scripts/compare-runs.js
+++ b/lighthouse-core/scripts/compare-runs.js
@@ -21,10 +21,10 @@ const glob = require('glob');
 const util = require('util');
 const execFile = util.promisify(require('child_process').execFile);
 const yargs = require('yargs');
+const {LH_ROOT} = require('../../root.js');
 
 const {ProgressLogger} = require('./lantern/collect/common.js');
 
-const LH_ROOT = `${__dirname}/../..`;
 const ROOT_OUTPUT_DIR = `${LH_ROOT}/timings-data`;
 
 const rawArgv = yargs
diff --git a/lighthouse-core/scripts/gcp-collection/fleet-create-directories.js b/lighthouse-core/scripts/gcp-collection/fleet-create-directories.js
index 2da26bce7..c91d6ff1e 100644
--- a/lighthouse-core/scripts/gcp-collection/fleet-create-directories.js
+++ b/lighthouse-core/scripts/gcp-collection/fleet-create-directories.js
@@ -14,8 +14,8 @@
 
 const fs = require('fs');
 const path = require('path');
+const {LH_ROOT} = require('../../../root.js');
 
-const LH_ROOT = path.join(__dirname, '../../../');
 const TMP_DIR = path.join(LH_ROOT, '.tmp/gcp-instances');
 const URLS_LIST = process.argv[2]
   ? path.resolve(process.cwd(), process.argv[2])
diff --git a/lighthouse-core/scripts/i18n/bake-ctc-to-lhl.js b/lighthouse-core/scripts/i18n/bake-ctc-to-lhl.js
index b12687afe..741d8ad58 100644
--- a/lighthouse-core/scripts/i18n/bake-ctc-to-lhl.js
+++ b/lighthouse-core/scripts/i18n/bake-ctc-to-lhl.js
@@ -10,8 +10,7 @@
 
 const fs = require('fs');
 const path = require('path');
-
-const LH_ROOT = path.join(__dirname, '../../../');
+const {LH_ROOT} = require('../../../root.js');
 
 /**
  * @typedef CtcMessage
diff --git a/lighthouse-core/scripts/i18n/collect-strings.js b/lighthouse-core/scripts/i18n/collect-strings.js
index 06178ab85..aff72ded3 100644
--- a/lighthouse-core/scripts/i18n/collect-strings.js
+++ b/lighthouse-core/scripts/i18n/collect-strings.js
@@ -18,8 +18,8 @@ const Util = require('../../../report/renderer/util.js');
 const {collectAndBakeCtcStrings} = require('./bake-ctc-to-lhl.js');
 const {pruneObsoleteLhlMessages} = require('./prune-obsolete-lhl-messages.js');
 const {countTranslatedMessages} = require('./count-translated.js');
+const {LH_ROOT} = require('../../../root.js');
 
-const LH_ROOT = path.join(__dirname, '../../../');
 const UISTRINGS_REGEX = /UIStrings = .*?\};\n/s;
 
 /** @typedef {import('./bake-ctc-to-lhl.js').CtcMessage} CtcMessage */
@@ -28,7 +28,7 @@ const UISTRINGS_REGEX = /UIStrings = .*?\};\n/s;
 
 const foldersWithStrings = [
   `${LH_ROOT}/lighthouse-core`,
-  `${LH_ROOT}/report`,
+  `${LH_ROOT}/report/renderer`,
   `${LH_ROOT}/lighthouse-treemap`,
   path.dirname(require.resolve('lighthouse-stack-packs')) + '/packs',
 ];
@@ -598,54 +598,43 @@ function writeStringsToCtcFiles(locale, strings) {
 }
 
 /**
- * This function does three things:
+ * This function does two things:
  *
- *    - Add `meaning` property to ctc messages that have the same message so TC can disambiguate (otherwise it fails to import).
- *    - Throw if the `meaning` of any collisions *also* collides (can't disambiguate messages).
+ *    - Add `meaning` property to ctc messages that have the same message but different descriptions so TC can disambiguate.
  *    - Throw if the known collisions has changed at all.
  *
  * @param {Record<string, CtcMessage>} strings
  */
 function resolveMessageCollisions(strings) {
-  /** @type {Map<string, Array<[string, CtcMessage]>>} */
+  /** @type {Map<string, Array<CtcMessage>>} */
   const stringsByMessage = new Map();
 
   // Group all the strings by their message.
-  for (const entry of Object.entries(strings)) {
-    const collisions = stringsByMessage.get(entry[1].message) || [];
-    collisions.push(entry);
-    stringsByMessage.set(entry[1].message, collisions);
+  for (const ctc of Object.values(strings)) {
+    const collisions = stringsByMessage.get(ctc.message) || [];
+    collisions.push(ctc);
+    stringsByMessage.set(ctc.message, collisions);
   }
 
-  /** @type {Array<[string, CtcMessage]>} */
+  /** @type {Array<CtcMessage>} */
   const allCollisions = [];
-  for (const group of stringsByMessage.values()) {
+  for (const messageGroup of stringsByMessage.values()) {
     // If this message didn't collide with anything else, skip it.
-    if (group.length <= 1) continue;
-    allCollisions.push(...group);
+    if (messageGroup.length <= 1) continue;
 
-    // We have a message collision, time to check collisions on the `meaning` property.
-    /** @type {Map<string|undefined, Array<[string, CtcMessage]>>} */
-    const stringsByMeaning = new Map();
-    for (const [key, ctc] of group) {
-      ctc.meaning = ctc.description;
-
-      const collisions = stringsByMeaning.get(ctc.meaning) || [];
-      collisions.push([key, ctc]);
-      stringsByMeaning.set(ctc.meaning, collisions);
-    }
+    // If group shares both message and description, they can be translated as if a single string.
+    const descriptions = new Set(messageGroup.map(ctc => ctc.description));
+    if (descriptions.size <= 1) continue;
 
-    for (const meaningGroup of stringsByMeaning.values()) {
-      if (meaningGroup.length <= 1) continue;
-
-      const debugMeaningList = meaningGroup.map(entry => [entry[0], entry[1].meaning].join('\n'));
-      const debugCollisionsMessage = `${meaningGroup[0][1].message}\n\n${debugMeaningList.join('\n\n')}`;
-      throw new Error(`Each strings' \`message\` or \`description\` must be different for the translation pipeline. The following keys did not have unique \`meaning\` values:\n\n${debugCollisionsMessage}`);
+    // We have duplicate messages with different descriptions. Disambiguate using `meaning` for TC.
+    for (const ctc of messageGroup) {
+      ctc.meaning = ctc.description;
     }
+    allCollisions.push(...messageGroup);
   }
 
-  // We survived fatal collisions, now check that the known collisions match our known list.
-  const collidingMessages = allCollisions.map(collision => collision[1].message).sort();
+  // Check that the known collisions match our known list.
+  const collidingMessages = allCollisions.map(collision => collision.message).sort();
 
   try {
     expect(collidingMessages).toEqual([
@@ -676,13 +665,9 @@ function resolveMessageCollisions(strings) {
       'Potential Savings',
       'URL',
       'URL',
-      'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. $LINK_START_0$Learn more$LINK_END_0$.',
-      'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. $LINK_START_0$Learn more$LINK_END_0$.',
-      'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. $LINK_START_0$Learn more$LINK_END_0$.',
-      'When an element doesn\'t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. $LINK_START_0$Learn more$LINK_END_0$.',
     ]);
   } catch (err) {
-    console.log('The number of duplicate strings have changed, update this assertion if that is expected, or reword strings');
+    console.log('The number of duplicate strings has changed. Consider duplicating the `description` to match existing strings so they\'re translated together or update this assertion if they must absolutely be translated separately');
     console.log('copy/paste this to pass check:');
     console.log(collidingMessages);
     throw new Error(err.message);
diff --git a/lighthouse-core/scripts/i18n/count-translated.js b/lighthouse-core/scripts/i18n/count-translated.js
index 9c1dafea9..d64da7eac 100644
--- a/lighthouse-core/scripts/i18n/count-translated.js
+++ b/lighthouse-core/scripts/i18n/count-translated.js
@@ -9,9 +9,10 @@
 
 const fs = require('fs');
 const glob = require('glob');
+const path = require('path');
+const {LH_ROOT} = require('../../../root.js');
 
-const lhRoot = `${__dirname}/../../../`;
-const enUsLhlFilename = lhRoot + 'lighthouse-core/lib/i18n/locales/en-US.json';
+const enUsLhlFilename = LH_ROOT + '/lighthouse-core/lib/i18n/locales/en-US.json';
 /** @type {LhlMessages} */
 const enUsLhl = JSON.parse(fs.readFileSync(enUsLhlFilename, 'utf8'));
 
@@ -30,7 +31,7 @@ function countTranslatedMessages() {
   const globPattern = 'lighthouse-core/lib/i18n/locales/**/+([-a-zA-Z0-9]).json';
   const localeFilenames = glob.sync(globPattern, {
     ignore,
-    cwd: lhRoot,
+    cwd: LH_ROOT,
   });
 
   /** @type {Array<[string, number]>} */
@@ -40,7 +41,7 @@ function countTranslatedMessages() {
   for (const localeFilename of localeFilenames) {
     // Use readFileSync in case other code in this process has altered the require()d form.
     /** @type {LhlMessages} */
-    const localeLhl = JSON.parse(fs.readFileSync(lhRoot + localeFilename, 'utf-8'));
+    const localeLhl = JSON.parse(fs.readFileSync(path.join(LH_ROOT, localeFilename), 'utf-8'));
 
     for (const localeKey of Object.keys(localeLhl)) {
       const messageCount = countPerMessage.get(localeKey);
diff --git a/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js b/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js
index 31841da18..724ec783a 100644
--- a/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js
+++ b/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js
@@ -9,8 +9,8 @@ const fs = require('fs');
 const path = require('path');
 const glob = require('glob');
 const MessageParser = require('intl-messageformat-parser').default;
-
 const {collectAllCustomElementsFromICU} = require('../../lib/i18n/i18n.js');
+const {LH_ROOT} = require('../../../root.js');
 
 /** @typedef {Record<string, {message: string}>} LhlMessages */
 
@@ -125,16 +125,15 @@ function pruneObsoleteLhlMessages() {
     '**/en-XL.json',
   ];
   const globPattern = 'lighthouse-core/lib/i18n/locales/**/+([-a-zA-Z0-9]).json';
-  const lhRoot = `${__dirname}/../../../`;
   const localePaths = glob.sync(globPattern, {
     ignore,
-    cwd: lhRoot,
+    cwd: LH_ROOT,
   });
 
   /** @type {Set<string>} */
   const alreadyLoggedPrunes = new Set();
   for (const localePath of localePaths) {
-    const absoluteLocalePath = path.join(lhRoot, localePath);
+    const absoluteLocalePath = path.join(LH_ROOT, localePath);
     // readFileSync so that the file is pulled again once updated by a collect-strings run
     const localeLhl = JSON.parse(fs.readFileSync(absoluteLocalePath, 'utf-8'));
     const prunedLocale = pruneLocale(goldenLocaleArgumentIds, localeLhl, alreadyLoggedPrunes);
diff --git a/lighthouse-core/scripts/internal-analysis/analyze-issues.js b/lighthouse-core/scripts/internal-analysis/analyze-issues.js
index 5b9457562..36be38a5f 100644
--- a/lighthouse-core/scripts/internal-analysis/analyze-issues.js
+++ b/lighthouse-core/scripts/internal-analysis/analyze-issues.js
@@ -10,6 +10,7 @@
 const fs = require('fs');
 const path = require('path');
 const log = require('lighthouse-logger');
+const {LH_ROOT} = require('../../../root.js');
 
 /**
  * @fileoverview Used in conjunction with `./download-issues.js` to analyze our Issue and PR response times as a team.
@@ -62,7 +63,7 @@ function normalizeIssue(issue) {
   return issue;
 }
 
-const ISSUES_PATH = path.join(__dirname, '../../../.tmp', '_issues.json');
+const ISSUES_PATH = path.join(LH_ROOT, '.tmp/_issues.json');
 /** @type {Array<AugmentedGitHubIssue>} */
 const _ISSUES = JSON.parse(fs.readFileSync(ISSUES_PATH, 'utf8')).map(normalizeIssue);
 const _ISSUES_SINCE = _ISSUES.filter(issue => new Date(issue.created_at).getTime() > START_AT);
diff --git a/lighthouse-core/scripts/internal-analysis/download-issues.js b/lighthouse-core/scripts/internal-analysis/download-issues.js
index 6174343bd..cf5d4e045 100644
--- a/lighthouse-core/scripts/internal-analysis/download-issues.js
+++ b/lighthouse-core/scripts/internal-analysis/download-issues.js
@@ -23,6 +23,7 @@
 const fs = require('fs');
 const path = require('path');
 const fetch = require('node-fetch');
+const {LH_ROOT} = require('../../../root.js');
 
 const DAY_FILTER = 90;
 const HOUR_IN_MS = 60 * 60 * 1000;
@@ -132,7 +133,7 @@ async function downloadIssues(urlToStartAt) {
 async function go() {
   const issues = await downloadIssues();
   fs.writeFileSync(
-    path.join(__dirname, '../../../.tmp', '_issues.json'),
+    path.join(LH_ROOT, '.tmp/_issues.json'),
     JSON.stringify(issues, null, 2)
   );
 }
diff --git a/lighthouse-core/scripts/lantern/collect/collect.js b/lighthouse-core/scripts/lantern/collect/collect.js
index cc75d44af..0f0e19ec7 100644
--- a/lighthouse-core/scripts/lantern/collect/collect.js
+++ b/lighthouse-core/scripts/lantern/collect/collect.js
@@ -14,8 +14,8 @@ const {execFile} = require('child_process');
 const {promisify} = require('util');
 const execFileAsync = promisify(execFile);
 const common = require('./common.js');
+const {LH_ROOT} = require('../../../../root.js');
 
-const LH_ROOT = `${__dirname}/../../../..`;
 const SAMPLES = process.env.SAMPLES ? Number(process.env.SAMPLES) : 9;
 const TEST_URLS = process.env.TEST_URLS ? process.env.TEST_URLS.split(' ') : require('./urls.js');
 
diff --git a/lighthouse-core/scripts/lantern/collect/common.js b/lighthouse-core/scripts/lantern/collect/common.js
index dc80ffb3d..8eecd8fa0 100644
--- a/lighthouse-core/scripts/lantern/collect/common.js
+++ b/lighthouse-core/scripts/lantern/collect/common.js
@@ -15,8 +15,8 @@ const readline = require('readline');
 const {promisify} = require('util');
 const archiver = require('archiver');
 const streamFinished = promisify(require('stream').finished);
+const {LH_ROOT} = require('../../../../root.js');
 
-const LH_ROOT = `${__dirname}/../../../..`;
 const collectFolder = `${LH_ROOT}/dist/collect-lantern-traces`;
 const summaryPath = `${collectFolder}/summary.json`;
 const goldenFolder = `${LH_ROOT}/dist/golden-lantern-traces`;
diff --git a/lighthouse-core/scripts/lantern/run-once.js b/lighthouse-core/scripts/lantern/run-once.js
index 2effd44e7..4f78c9b5e 100755
--- a/lighthouse-core/scripts/lantern/run-once.js
+++ b/lighthouse-core/scripts/lantern/run-once.js
@@ -11,6 +11,7 @@ const path = require('path');
 const PredictivePerf = require('../../audits/predictive-perf.js');
 const Simulator = require('../../lib/dependency-graph/simulator/simulator.js');
 const traceSaver = require('../../lib/lantern-trace-saver.js');
+const {LH_ROOT} = require('../../../root.js');
 
 if (process.argv.length !== 4) throw new Error('Usage $0 <trace file> <devtools file>');
 
@@ -32,7 +33,7 @@ async function run() {
   const pessimisticTTINodeTimings = Simulator.ALL_NODE_TIMINGS.get('pessimisticInteractive');
   if (process.env.LANTERN_DEBUG && pessimisticTTINodeTimings) {
     const outputTraceFile = path.basename(tracePath).replace(/.trace.json$/, '.lantern.trace.json');
-    const outputTracePath = path.join(__dirname, '../../../.tmp', outputTraceFile);
+    const outputTracePath = path.join(LH_ROOT, '.tmp', outputTraceFile);
     const trace = traceSaver.convertNodeTimingsToTrace(pessimisticTTINodeTimings);
     fs.writeFileSync(outputTracePath, JSON.stringify(trace, null, 2));
   }
diff --git a/lighthouse-core/scripts/open-devtools.sh b/lighthouse-core/scripts/open-devtools.sh
index 1f83ef68e..33145d5df 100644
--- a/lighthouse-core/scripts/open-devtools.sh
+++ b/lighthouse-core/scripts/open-devtools.sh
@@ -36,7 +36,6 @@ if ! which gn ; then
   export PYTHONPATH="${PYTHONPATH:-}:$BLINK_TOOLS_PATH/latest/third_party/typ"
 fi
 
-yarn build-devtools
 yarn devtools "$DEVTOOLS_PATH"
 
 cd "$DEVTOOLS_PATH"
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 170c7aa43..e99dc27da 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -39,6 +39,9 @@ mkdir -p "$fe_lh_dir"
 
 lh_bg_js="dist/lighthouse-dt-bundle.js"
 
+yarn build-report
+yarn build-devtools
+
 # copy lighthouse-dt-bundle (potentially stale)
 cp -pPR "$lh_bg_js" "$fe_lh_dir/lighthouse-dt-bundle.js"
 echo -e "$check (Potentially stale) lighthouse-dt-bundle copied."
diff --git a/package.json b/package.json
index 535bad0db..0413e87e7 100644
--- a/package.json
+++ b/package.json
@@ -13,8 +13,8 @@
   },
   "scripts": {
     "build-all": "npm-run-posix-or-windows build-all:task",
-    "build-all:task": "yarn build-cdt-lib && yarn build-devtools && (yarn build-extension & yarn build-lr & yarn build-viewer & yarn build-treemap & yarn build-smokehouse-bundle & wait) && yarn build-pack",
-    "build-all:task:windows": "yarn build-cdt-lib && yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer && yarn build-treemap && yarn build-smokehouse-bundle",
+    "build-all:task": "yarn build-report && yarn build-cdt-lib && yarn build-devtools && (yarn build-extension & yarn build-lr & yarn build-viewer & yarn build-treemap & yarn build-smokehouse-bundle & wait) && yarn build-pack",
+    "build-all:task:windows": "yarn build-report && yarn build-cdt-lib && yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer && yarn build-treemap && yarn build-smokehouse-bundle",
     "build-cdt-lib": "node ./build/build-cdt-lib.js",
     "build-extension": "yarn build-extension-chrome && yarn build-extension-firefox",
     "build-extension-chrome": "node ./build/build-extension.js chrome",
@@ -23,6 +23,7 @@
     "build-smokehouse-bundle": "node ./build/build-smokehouse-bundle.js",
     "build-lr": "yarn reset-link && node ./build/build-lightrider-bundles.js",
     "build-pack": "bash build/build-pack.sh",
+    "build-report": "node build/build-report.js",
     "build-treemap": "node ./build/build-treemap.js",
     "build-viewer": "node ./build/build-viewer.js",
     "reset-link": "(yarn unlink || true) && yarn link && yarn link lighthouse",
@@ -31,7 +32,7 @@
     "lint": "[ \"$CI\" = true ] && eslint --quiet -f codeframe . || eslint .",
     "smoke": "node lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js",
     "debug": "node --inspect-brk ./lighthouse-cli/index.js",
-    "start": "node ./lighthouse-cli/index.js",
+    "start": "yarn build-report && node ./lighthouse-cli/index.js",
     "test": "yarn diff:sample-json && yarn lint --quiet && yarn unit && yarn type-check",
     "test-bundle": "yarn smoke --runner bundle -j=1 --retries=2 --invert-match forms",
     "test-clients": "jest \"clients/\"",
diff --git a/report/assets/standalone-template.html b/report/assets/standalone-template.html
index 96c481103..e7cd816d4 100644
--- a/report/assets/standalone-template.html
+++ b/report/assets/standalone-template.html
@@ -31,50 +31,10 @@ limitations under the License.
 
   <div id="lh-log"></div>
 
+  <script>window.__LIGHTHOUSE_JSON__ = %%LIGHTHOUSE_JSON%%;</script>
   <script>%%LIGHTHOUSE_JAVASCRIPT%%
   //# sourceURL=compiled-reportrenderer.js
   </script>
-  <script>window.__LIGHTHOUSE_JSON__ = %%LIGHTHOUSE_JSON%%;</script>
   <script>console.log('window.__LIGHTHOUSE_JSON__', __LIGHTHOUSE_JSON__);</script>
-  <script>
-    function __initLighthouseReport__() {
-      const dom = new DOM(document);
-      const renderer = new ReportRenderer(dom);
-
-      const container = document.querySelector('main');
-      renderer.renderReport(window.__LIGHTHOUSE_JSON__, container);
-
-      // Hook in JS features and page-level event listeners after the report
-      // is in the document.
-      const features = new ReportUIFeatures(dom);
-      features.initFeatures(window.__LIGHTHOUSE_JSON__);
-    }
-    window.addEventListener('DOMContentLoaded', __initLighthouseReport__);
-
-    document.addEventListener('lh-analytics', e => {
-      if (window.ga) {
-        ga(e.detail.cmd, e.detail.fields);
-      }
-    });
-
-    document.addEventListener('lh-log', e => {
-      const logger = new Logger(document.querySelector('#lh-log'));
-
-      switch (e.detail.cmd) {
-        case 'log':
-          logger.log(e.detail.msg);
-          break;
-        case 'warn':
-          logger.warn(e.detail.msg);
-          break;
-        case 'error':
-          logger.error(e.detail.msg);
-          break;
-        case 'hide':
-          logger.hide();
-          break;
-      }
-    });
-  </script>
 </body>
 </html>
diff --git a/report/clients/standalone.js b/report/clients/standalone.js
new file mode 100644
index 000000000..b45511233
--- /dev/null
+++ b/report/clients/standalone.js
@@ -0,0 +1,52 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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';
+
+/* global document window ga DOM ReportRenderer ReportUIFeatures Logger */
+
+(function __initLighthouseReport__() {
+  const dom = new DOM(document);
+  const renderer = new ReportRenderer(dom);
+  const container = dom.find('main', document);
+  /** @type {LH.ReportResult} */
+  // @ts-expect-error
+  const lhr = window.__LIGHTHOUSE_JSON__;
+  renderer.renderReport(lhr, container);
+
+  // Hook in JS features and page-level event listeners after the report
+  // is in the document.
+  const features = new ReportUIFeatures(dom);
+  features.initFeatures(lhr);
+})();
+
+document.addEventListener('lh-analytics', /** @param {Event} e */ e => {
+  // @ts-expect-error
+  if (window.ga) ga(e.detail.cmd, e.detail.fields);
+});
+
+document.addEventListener('lh-log', /** @param {Event} e */ e => {
+  const el = document.querySelector('#lh-log');
+  if (!el) return;
+
+  const logger = new Logger(el);
+  // @ts-expect-error
+  const detail = e.detail;
+
+  switch (detail.cmd) {
+    case 'log':
+      logger.log(detail.msg);
+      break;
+    case 'warn':
+      logger.warn(detail.msg);
+      break;
+    case 'error':
+      logger.error(detail.msg);
+      break;
+    case 'hide':
+      logger.hide();
+      break;
+  }
+});
diff --git a/report/report-assets.js b/report/report-assets.js
index b92b031bf..b54411100 100644
--- a/report/report-assets.js
+++ b/report/report-assets.js
@@ -7,25 +7,8 @@
 
 const fs = require('fs');
 
-const REPORT_TEMPLATE =
-  fs.readFileSync(__dirname + '/assets/standalone-template.html', 'utf8');
-const REPORT_JAVASCRIPT = [
-  fs.readFileSync(__dirname + '/renderer/util.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/dom.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/details-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/crc-details-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/snippet-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/element-screenshot-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/../lighthouse-core/lib/file-namer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/logger.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/report-ui-features.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/category-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/performance-category-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/pwa-category-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/report-renderer.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/i18n.js', 'utf8'),
-  fs.readFileSync(__dirname + '/renderer/text-encoding.js', 'utf8'),
-].join(';\n');
+const REPORT_TEMPLATE = fs.readFileSync(__dirname + '/assets/standalone-template.html', 'utf8');
+const REPORT_JAVASCRIPT = fs.readFileSync(__dirname + '/../dist/report/standalone.js', 'utf8');
 const REPORT_CSS = fs.readFileSync(__dirname + '/assets/styles.css', 'utf8');
 const REPORT_TEMPLATES = fs.readFileSync(__dirname + '/assets/templates.html', 'utf8');
 
diff --git a/root.js b/root.js
new file mode 100644
index 000000000..ecf261013
--- /dev/null
+++ b/root.js
@@ -0,0 +1,8 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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';
+
+module.exports.LH_ROOT = __dirname;
diff --git a/third-party/download-content-shell/download-content-shell.js b/third-party/download-content-shell/download-content-shell.js
index b69ef9a15..1d5c36971 100644
--- a/third-party/download-content-shell/download-content-shell.js
+++ b/third-party/download-content-shell/download-content-shell.js
@@ -11,12 +11,13 @@ const fs = require('fs');
 const path = require('path');
 const shell = require('child_process').execSync;
 const utils = require('./utils.js');
+const {LH_ROOT} = require('../../root.js');
 
 const TARGET = 'Release';
 const CONTENT_SHELL_ZIP = 'content-shell.zip';
 const MAX_CONTENT_SHELLS = 10;
 const PLATFORM = getPlatform();
-const LH_ROOT = `${__dirname}/../..`;
+
 const CACHE_PATH = path.resolve(LH_ROOT, '.tmp', 'chromium-web-tests', 'content-shells');
 const COMMIT_POSITION_UPDATE_PERIOD = 420;
 


Test patch:
diff --git a/clients/test/extension/popup-test.js b/clients/test/extension/popup-test.js
index 40654c78d..5fd937f6e 100644
--- a/clients/test/extension/popup-test.js
+++ b/clients/test/extension/popup-test.js
@@ -11,8 +11,9 @@ const path = require('path');
 const puppeteer = require('puppeteer');
 const {DEFAULT_CATEGORIES, STORAGE_KEYS} =
   require('../../extension/scripts/settings-controller.js');
+const {LH_ROOT} = require('../../../root.js');
 
-const lighthouseExtensionPath = path.resolve(__dirname, '../../../dist/extension-chrome');
+const lighthouseExtensionPath = path.resolve(LH_ROOT, 'dist/extension-chrome');
 
 const mockStorage = {
   [STORAGE_KEYS.Categories]: {
diff --git a/lighthouse-cli/test/cli/run-test.js b/lighthouse-cli/test/cli/run-test.js
index 06c8aae5c..24a03ace6 100644
--- a/lighthouse-cli/test/cli/run-test.js
+++ b/lighthouse-cli/test/cli/run-test.js
@@ -9,9 +9,10 @@
 const assert = require('assert').strict;
 const path = require('path');
 const fs = require('fs');
-
 const run = require('../../run.js');
 const parseChromeFlags = require('../../run.js').parseChromeFlags;
+const {LH_ROOT} = require('../../../root.js');
+
 /** @type {LH.Config.Json} */
 const testConfig = {
   'extends': 'lighthouse:default',
@@ -39,7 +40,7 @@ describe('CLI run', function() {
     beforeAll(async () => {
       const url = 'http://localhost:10200/dobetterweb/dbw_tester.html';
       // eslint-disable-next-line max-len
-      const samplev2ArtifactsPath = __dirname + '/../../../lighthouse-core/test/results/artifacts/';
+      const samplev2ArtifactsPath = LH_ROOT + '/lighthouse-core/test/results/artifacts/';
 
       // eslint-disable-next-line max-len
       const flags = getFlags([
diff --git a/lighthouse-cli/test/fixtures/static-server.js b/lighthouse-cli/test/fixtures/static-server.js
index 94644b039..6fe09eaa6 100644
--- a/lighthouse-cli/test/fixtures/static-server.js
+++ b/lighthouse-cli/test/fixtures/static-server.js
@@ -17,9 +17,9 @@ const mime = require('mime-types');
 const parseQueryString = require('querystring').parse;
 const parseURL = require('url').parse;
 const URLSearchParams = require('url').URLSearchParams;
-const HEADER_SAFELIST = new Set(['x-robots-tag', 'link', 'content-security-policy']);
+const {LH_ROOT} = require('../../../root.js');
 
-const lhRootDirPath = path.join(__dirname, '../../../');
+const HEADER_SAFELIST = new Set(['x-robots-tag', 'link', 'content-security-policy']);
 
 class Server {
   baseDir = __dirname;
@@ -192,7 +192,7 @@ class Server {
 
     // Disallow file requests outside of LH folder
     const filePathDir = path.parse(absoluteFilePath).dir;
-    if (!filePathDir.startsWith(lhRootDirPath)) {
+    if (!filePathDir.startsWith(LH_ROOT)) {
       return readFileCallback(new Error('Disallowed path'));
     }
 
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
index b9f4a8f6d..141492041 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
@@ -215,10 +215,15 @@ const expectations = [
         },
         'modern-image-formats': {
           details: {
-            overallSavingsBytes: '>60000',
-            items: {
-              length: 6,
-            },
+            overallSavingsBytes: '137000 +/- 10000',
+            items: [
+              {url: /lighthouse-1024x680.jpg$/},
+              {url: /lighthouse-unoptimized.jpg$/},
+              {url: /lighthouse-480x320.jpg$/},
+              {url: /lighthouse-480x320.jpg\?attributesized/},
+              {url: /lighthouse-480x320.jpg\?css/},
+              {url: /lighthouse-480x320.jpg\?sprite/},
+            ],
           },
         },
         'uses-text-compression': {
diff --git a/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
index 269707f45..9da94ca5b 100644
--- a/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
+++ b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
@@ -21,9 +21,9 @@ const path = require('path');
 const colors = require('colors');
 const LegacyJavascript = require('../../audits/byte-efficiency/legacy-javascript.js');
 const i18n = require('../../lib/i18n/i18n.js');
+const {LH_ROOT} = require('../../../root.js');
 
-const LH_ROOT_DIR = path.join(__dirname, '../../../');
-const LATEST_RUN_DIR = path.join(LH_ROOT_DIR, 'latest-run');
+const LATEST_RUN_DIR = path.join(LH_ROOT, 'latest-run');
 
 /** @param {LH.DevtoolsLog} log */
 function requestUrlToId(log) {
diff --git a/lighthouse-core/test/audits/byte-efficiency/modern-image-formats-test.js b/lighthouse-core/test/audits/byte-efficiency/modern-image-formats-test.js
index dfc8ce0b8..5d82f9d2c 100644
--- a/lighthouse-core/test/audits/byte-efficiency/modern-image-formats-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/modern-image-formats-test.js
@@ -48,14 +48,14 @@ function generateArtifacts(images) {
 
 describe('Page uses optimized images', () => {
   it('ignores files when there is only insignificant savings', () => {
-    const artifacts = generateArtifacts([{originalSize: 5000, webpSize: 4500}]);
+    const artifacts = generateArtifacts([{originalSize: 5000, jpegSize: 10000, webpSize: 4500}]);
     const auditResult = ModernImageFormats.audit_(artifacts);
 
     expect(auditResult.items).toEqual([]);
   });
 
-  it('flags files when there is only small savings', () => {
-    const artifacts = generateArtifacts([{originalSize: 15000, webpSize: 4500}]);
+  it('flags files using AVIF savings', () => {
+    const artifacts = generateArtifacts([{originalSize: 15000, jpegSize: 8000, webpSize: 5000}]);
     const auditResult = ModernImageFormats.audit_(artifacts);
 
     expect(auditResult.items).toEqual([
@@ -63,7 +63,8 @@ describe('Page uses optimized images', () => {
         fromProtocol: true,
         isCrossOrigin: false,
         totalBytes: 15000,
-        wastedBytes: 15000 - 4500,
+        wastedBytes: 15000 - (5000 * 0.8),
+        wastedWebpBytes: 15000 - 5000,
         url: 'http://google.com/image.jpeg',
       },
     ]);
@@ -78,7 +79,8 @@ describe('Page uses optimized images', () => {
         fromProtocol: false,
         isCrossOrigin: false,
         totalBytes: 1e6,
-        wastedBytes: 1e6 - 1000 * 1000 * 2 / 10,
+        wastedBytes: Math.round(1e6 - 1000 * 1000 * 2 / 12),
+        wastedWebpBytes: Math.round(1e6 - 1000 * 1000 * 2 / 10),
         url: 'http://google.com/image.jpeg',
       },
     ]);
@@ -86,7 +88,7 @@ describe('Page uses optimized images', () => {
 
   it('estimates savings on cross-origin files', () => {
     const artifacts = generateArtifacts([{
-      url: 'http://localhost:1234/image.jpeg', originalSize: 50000, webpSize: 20000,
+      url: 'http://localhost:1234/image.jpg', originalSize: 50000, jpegSize: 50000, webpSize: 20000,
     }]);
     const auditResult = ModernImageFormats.audit_(artifacts);
 
@@ -94,16 +96,27 @@ describe('Page uses optimized images', () => {
       {
         fromProtocol: true,
         isCrossOrigin: true,
-        url: 'http://localhost:1234/image.jpeg',
+        url: 'http://localhost:1234/image.jpg',
       },
     ]);
   });
 
   it('passes when all images are sufficiently optimized', () => {
     const artifacts = generateArtifacts([
-      {type: 'png', originalSize: 50000, webpSize: 50001},
-      {type: 'jpeg', originalSize: 50000, webpSize: 50001},
-      {type: 'bmp', originalSize: 4000, webpSize: 2000},
+      {type: 'png', originalSize: 50000, jpegSize: 100001, webpSize: 60001},
+      {type: 'jpeg', originalSize: 50000, jpegSize: 100001, webpSize: 60001},
+      {type: 'bmp', originalSize: 4000, webpSize: 5001},
+    ]);
+
+    const auditResult = ModernImageFormats.audit_(artifacts);
+
+    expect(auditResult.items).toEqual([]);
+  });
+
+  it('passes when all images are already using a modern format', () => {
+    const artifacts = generateArtifacts([
+      {type: 'webp', originalSize: 50000, jpegSize: 100000, webpSize: 100000},
+      {type: 'avif', originalSize: 50000, jpegSize: 100000, webpSize: 100000},
     ]);
 
     const auditResult = ModernImageFormats.audit_(artifacts);
@@ -113,7 +126,7 @@ describe('Page uses optimized images', () => {
 
   it('elides data URIs', () => {
     const artifacts = generateArtifacts([
-      {type: 'data:webp', originalSize: 15000, webpSize: 4500},
+      {type: 'data:jpeg', originalSize: 15000, jpegSize: 10000, webpSize: 4500},
     ]);
 
     const auditResult = ModernImageFormats.audit_(artifacts);
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-snapshot-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-snapshot-test.js
new file mode 100644
index 000000000..7d09140d1
--- /dev/null
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-snapshot-test.js
@@ -0,0 +1,164 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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 UsesResponsiveImagesSnapshot =
+    require('../../../audits/byte-efficiency/uses-responsive-images-snapshot.js');
+
+/* eslint-env jest */
+
+/**
+ * @param {Partial<LH.Artifacts.ImageElement>=} partial
+ * @return {LH.Artifacts.ImageElement}
+ */
+function mockElement(partial = {}) {
+  return {
+    src: 'https://www.paulirish.com/avatar150.jpg',
+    srcset: '',
+    displayedWidth: 200,
+    displayedHeight: 200,
+    clientRect: {
+      top: 50,
+      bottom: 250,
+      left: 50,
+      right: 250,
+    },
+    attributeWidth: '',
+    attributeHeight: '',
+    naturalDimensions: undefined,
+    cssEffectiveRules: undefined,
+    computedStyles: {position: 'absolute', objectFit: '', imageRendering: ''},
+    isCss: false,
+    isPicture: false,
+    isInShadowDOM: false,
+    node: {
+      lhId: '__nodeid__',
+      devtoolsNodePath: '1,HTML,1,BODY,1,DIV,1,IMG',
+      selector: 'body > div > img',
+      nodeLabel: 'img',
+      snippet: '<img src="https://www.paulirish.com/avatar150.jpg">',
+      boundingRect: {
+        top: 50,
+        bottom: 250,
+        left: 50,
+        right: 250,
+        width: 200,
+        height: 200,
+      },
+    },
+    ...partial,
+  };
+}
+
+it('reports images that are bigger than displayed dimensions', async () => {
+  const artifacts = {
+    ImageElements: [
+      mockElement({naturalDimensions: {width: 500, height: 500}}),
+    ],
+    ViewportDimensions: {
+      width: 500,
+      height: 500,
+      devicePixelRatio: 1,
+    },
+  };
+
+  const result = await UsesResponsiveImagesSnapshot.audit(artifacts);
+
+  expect(result.score).toEqual(0);
+  expect(result.details.items).toEqual([
+    {
+      actualDimensions: '500x500',
+      displayedDimensions: '200x200',
+      url: 'https://www.paulirish.com/avatar150.jpg',
+    },
+  ]);
+});
+
+it('ignores images smaller or equal to displayed dimensions', async () => {
+  const artifacts = {
+    ImageElements: [
+      mockElement({naturalDimensions: {width: 200, height: 200}}),
+      mockElement({naturalDimensions: {width: 40, height: 40}}),
+    ],
+    ViewportDimensions: {
+      width: 500,
+      height: 500,
+      devicePixelRatio: 1,
+    },
+  };
+
+  const result = await UsesResponsiveImagesSnapshot.audit(artifacts);
+
+  expect(result.score).toEqual(1);
+  expect(result.details.items).toEqual([]);
+});
+
+it('ignores images with no natural dimensions', async () => {
+  const artifacts = {
+    ImageElements: [
+      mockElement(),
+    ],
+    ViewportDimensions: {
+      width: 500,
+      height: 500,
+      devicePixelRatio: 1,
+    },
+  };
+
+  const result = await UsesResponsiveImagesSnapshot.audit(artifacts);
+
+  expect(result.score).toEqual(1);
+  expect(result.details.items).toEqual([]);
+});
+
+it('uses pixel ratio to compute used pixels', async () => {
+  const artifacts = {
+    ImageElements: [
+      mockElement({naturalDimensions: {width: 400, height: 400}}),
+      mockElement({naturalDimensions: {width: 500, height: 500}}),
+    ],
+    ViewportDimensions: {
+      width: 500,
+      height: 500,
+      devicePixelRatio: 2,
+    },
+  };
+
+  const result = await UsesResponsiveImagesSnapshot.audit(artifacts);
+
+  expect(result.score).toEqual(0);
+  expect(result.details.items).toEqual([
+    {
+      actualDimensions: '500x500',
+      displayedDimensions: '400x400',
+      url: 'https://www.paulirish.com/avatar150.jpg',
+    },
+  ]);
+});
+
+it('passes if pixel difference is within the threshold', async () => {
+  const artifacts = {
+    ImageElements: [
+      mockElement({naturalDimensions: {width: 201, height: 200}}),
+    ],
+    ViewportDimensions: {
+      width: 500,
+      height: 500,
+      devicePixelRatio: 1,
+    },
+  };
+
+  const result = await UsesResponsiveImagesSnapshot.audit(artifacts);
+
+  expect(result.score).toEqual(1);
+  expect(result.details.items).toEqual([
+    {
+      actualDimensions: '201x200',
+      displayedDimensions: '200x200',
+      url: 'https://www.paulirish.com/avatar150.jpg',
+    },
+  ]);
+});
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index 16635b0db..13f775f98 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -95,7 +95,7 @@ describe('Fraggle Rock API', () => {
 
       const {auditResults, erroredAudits, failedAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`81`);
+      expect(auditResults.length).toMatchInlineSnapshot(`82`);
 
       expect(erroredAudits).toHaveLength(0);
       expect(failedAudits.map(audit => audit.id)).toContain('label');
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index d22c433a1..8b14cdf2f 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -3823,12 +3823,12 @@
     "modern-image-formats": {
       "id": "modern-image-formats",
       "title": "Serve images in next-gen formats",
-      "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images/).",
+      "description": "Image formats like WebP and AVIF often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images/).",
       "score": 0.88,
       "scoreDisplayMode": "numeric",
       "numericValue": 150,
       "numericUnit": "millisecond",
-      "displayValue": "Potential savings of 9 KiB",
+      "displayValue": "Potential savings of 13 KiB",
       "warnings": [],
       "details": {
         "type": "opportunity",
@@ -3860,11 +3860,12 @@
             "fromProtocol": true,
             "isCrossOrigin": false,
             "totalBytes": 24620,
-            "wastedBytes": 9028
+            "wastedBytes": 12989.45,
+            "wastedWebpBytes": 9028
           }
         ],
         "overallSavingsMs": 150,
-        "overallSavingsBytes": 9028
+        "overallSavingsBytes": 12989.45
       }
     },
     "uses-optimized-images": {
@@ -8172,7 +8173,7 @@
         },
         {
           "values": {
-            "wastedBytes": 9028
+            "wastedBytes": 12989.45
           },
           "path": "audits[modern-image-formats].displayValue"
         },
diff --git a/lighthouse-core/test/test-utils.js b/lighthouse-core/test/test-utils.js
index 7246eeaef..832025518 100644
--- a/lighthouse-core/test/test-utils.js
+++ b/lighthouse-core/test/test-utils.js
@@ -11,6 +11,7 @@ const fs = require('fs');
 const i18n = require('../lib/i18n/i18n.js');
 const mockCommands = require('./gather/mock-commands.js');
 const {default: {toBeCloseTo}} = require('expect/build/matchers.js');
+const {LH_ROOT} = require('../../root.js');
 
 expect.extend({
   toBeDisplayString(received, expected) {
@@ -89,7 +90,7 @@ function getProtoRoundTrip() {
   let itIfProtoExists;
   try {
     sampleResultsRoundtripStr =
-      fs.readFileSync(__dirname + '/../../.tmp/sample_v2_round_trip.json', 'utf-8');
+      fs.readFileSync(LH_ROOT + '/.tmp/sample_v2_round_trip.json', 'utf-8');
     describeIfProtoExists = describe;
     itIfProtoExists = it;
   } catch (err) {
diff --git a/lighthouse-viewer/test/viewer-test-pptr.js b/lighthouse-viewer/test/viewer-test-pptr.js
index dcf3feb7f..2f9b567f7 100644
--- a/lighthouse-viewer/test/viewer-test-pptr.js
+++ b/lighthouse-viewer/test/viewer-test-pptr.js
@@ -11,14 +11,15 @@ const path = require('path');
 const fs = require('fs');
 const assert = require('assert').strict;
 const puppeteer = require('puppeteer');
-
 const {server} = require('../../lighthouse-cli/test/fixtures/static-server.js');
+const {LH_ROOT} = require('../../root.js');
+
 const portNumber = 10200;
 const viewerUrl = `http://localhost:${portNumber}/dist/gh-pages/viewer/index.html`;
-const sampleLhr = __dirname + '/../../lighthouse-core/test/results/sample_v2.json';
+const sampleLhr = LH_ROOT + '/lighthouse-core/test/results/sample_v2.json';
 
 const defaultConfig =
-  require(path.resolve(__dirname, '../../lighthouse-core/config/default-config.js'));
+  require(path.resolve(LH_ROOT, './lighthouse-core/config/default-config.js'));
 const lighthouseCategories = Object.keys(defaultConfig.categories);
 const getAuditsOfCategory = category => defaultConfig.categories[category].auditRefs;
 
diff --git a/report/test/renderer/psi-test.js b/report/test/renderer/psi-test.js
index 64c7f0f0d..15431dc0f 100644
--- a/report/test/renderer/psi-test.js
+++ b/report/test/renderer/psi-test.js
@@ -21,10 +21,11 @@ const DetailsRenderer = require('../../renderer/details-renderer.js');
 const CriticalRequestChainRenderer = require('../../renderer/crc-details-renderer.js');
 const ElementScreenshotRenderer = require('../../renderer/element-screenshot-renderer.js');
 const ReportUIFeatures = require('../../renderer/report-ui-features.js');
+const {LH_ROOT} = require('../../../root.js');
 
 const {itIfProtoExists, sampleResultsRoundtripStr} = testUtils.getProtoRoundTrip();
 const sampleResultsStr =
-  fs.readFileSync(__dirname + '/../../../lighthouse-core/test/results/sample_v2.json', 'utf-8');
+  fs.readFileSync(LH_ROOT + '/lighthouse-core/test/results/sample_v2.json', 'utf-8');
 
 /* eslint-env jest */
 
diff --git a/report/test/renderer/report-ui-features-test.js b/report/test/renderer/report-ui-features-test.js
index 8db7fb90b..941503e83 100644
--- a/report/test/renderer/report-ui-features-test.js
+++ b/report/test/renderer/report-ui-features-test.js
@@ -129,7 +129,10 @@ describe('ReportUIFeatures', () => {
       beforeAll(() => {
         const lhr = JSON.parse(JSON.stringify(sampleResults));
         lhr.requestedUrl = lhr.finalUrl = 'http://www.example.com';
-        const webpAuditItemTemplate = sampleResults.audits['modern-image-formats'].details.items[0];
+        const webpAuditItemTemplate = {
+          ...sampleResults.audits['modern-image-formats'].details.items[0],
+          wastedBytes: 8.8 * 1024,
+        };
         const renderBlockingAuditItemTemplate =
           sampleResults.audits['render-blocking-resources'].details.items[0];
         const textCompressionAuditItemTemplate =
diff --git a/third-party/chromium-synchronization/inspector-issueAdded-types-test.js b/third-party/chromium-synchronization/inspector-issueAdded-types-test.js
index cf0d247e5..a2c699434 100644
--- a/third-party/chromium-synchronization/inspector-issueAdded-types-test.js
+++ b/third-party/chromium-synchronization/inspector-issueAdded-types-test.js
@@ -7,9 +7,10 @@
 
 const fs = require('fs');
 const fetch = require('node-fetch');
+const {LH_ROOT} = require('../../../root.js');
 
-const inspectorIssuesGathererPath = __dirname +
-    '/../../lighthouse-core/gather/gatherers/inspector-issues.js';
+const inspectorIssuesGathererPath = LH_ROOT +
+  '/lighthouse-core/gather/gatherers/inspector-issues.js';
 const inspectorIssuesGathererSource = fs.readFileSync(inspectorIssuesGathererPath, 'utf-8');
 
 /* eslint-env jest */