Repository: GoogleChrome/lighthouse
Pull request #12724 (2021-06-30 05:35:32)

misc: get LH_ROOT via new file root.js
Replaces all the `LH_ROOT = '../../../'` madness with `const {LH_ROOT} = require('../../../root.js')`. This does a few things:

* Can just type `LH_ROO+<enter>` to auto-import this value to a file
* Statically verified (no more doing directory calculus to know how many levels up to go) (is this only hard for me?)
* Prepares the way for #12689 (getting the directory of current file is no longer as simple as `__dirname`, so having the code in one place means less future boilerplate)

Beyond changing all instances of `LH_ROOT = ...`, I also grokked for lines that use `__dirname` to form a path that goes to the root, and changed them to use `LH_ROOT`.

_____

I did not modify any files that use `__dirname` w/o crossing over the root directory (ex: `report-assets.js` does `__dirname + '/renderer/util.js'`)–I have some ideas for that, maybe worth discussing here:

1) could have a function `getRelativePathEsm(importMeta, ...pathSegments)` (usage: `getRelativePathEsm(import.meta, 'somefile.json')`)
2) `root.js` could also export all the top-level directories (`LH_CORE`, `LH_CLI`, etc...)
3) could just use `LH_ROOT` for everything, and have all paths used in code be relative to root

( the motivation is to avoid this code everywhere https://stackoverflow.com/a/50052194/2788187 )

adamraine: Any chance we can use [module aliases](https://www.npmjs.com/package/module-alias)?

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/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/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..d3b91964c 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"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 2c301c4be..d068a57c3 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̂"
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..2f5b20feb 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',
 ];
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/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 */