Repository: GoogleChrome/lighthouse
Pull request #12892 (2021-08-10 18:25:55)

clients(treemap): convert to ES modules
ref #12689



Patch:
diff --git a/build/build-report.js b/build/build-report.js
index d4ca39814..c23a17243 100644
--- a/build/build-report.js
+++ b/build/build-report.js
@@ -41,20 +41,6 @@ async function buildPsiReport() {
   });
 }
 
-async function buildTreemapReport() {
-  const bundle = await rollup.rollup({
-    input: 'report/clients/treemap.js',
-    plugins: [
-      commonjs(),
-    ],
-  });
-
-  await bundle.write({
-    file: 'dist/report/treemap.js',
-    format: 'iife',
-  });
-}
-
 async function buildEsModulesBundle() {
   const bundle = await rollup.rollup({
     input: 'report/clients/bundle.js',
@@ -85,18 +71,29 @@ async function buildUmdBundle() {
 }
 
 if (require.main === module) {
-  if (process.argv[2] === '--only-standalone') {
-    buildStandaloneReport();
-  } else {
+  if (process.argv.length <= 2) {
     buildStandaloneReport();
+    buildEsModulesBundle();
+    buildPsiReport();
     buildUmdBundle();
+  }
+
+  if (process.argv.includes('--psi')) {
+    buildPsiReport();
+  }
+  if (process.argv.includes('--standalone')) {
+    buildStandaloneReport();
+  }
+  if (process.argv.includes('--esm')) {
     buildEsModulesBundle();
   }
+  if (process.argv.includes('--umd')) {
+    buildUmdBundle();
+  }
 }
 
 module.exports = {
   buildStandaloneReport,
   buildPsiReport,
   buildUmdBundle,
-  buildTreemapReport,
 };
diff --git a/lighthouse-core/scripts/build-report-for-autodeployment.js b/build/build-sample-reports.js
similarity index 70%
rename from lighthouse-core/scripts/build-report-for-autodeployment.js
rename to build/build-sample-reports.js
index 963c03e6d..963639505 100644
--- a/lighthouse-core/scripts/build-report-for-autodeployment.js
+++ b/build/build-sample-reports.js
@@ -10,12 +10,14 @@
 /* eslint-disable no-console */
 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 swapLocale = require('../lighthouse-core/lib/i18n/swap-locale.js');
+const ReportGenerator = require('../lighthouse-core/../report/report-generator.js');
+const {defaultSettings} = require('../lighthouse-core/config/constants.js');
+const lighthouse = require('../lighthouse-core/index.js');
+const lhr = /** @type {LH.Result} */ (require('../lighthouse-core/test/results/sample_v2.json'));
+const {LH_ROOT} = require('../root.js');
+const htmlReportAssets = require('../lighthouse-core/../report/report-assets.js');
+
 
 const DIST = path.join(LH_ROOT, `dist/now`);
 
@@ -29,19 +31,23 @@ const DIST = path.join(LH_ROOT, `dist/now`);
     'ɑrabic': swapLocale(lhr, 'ar').lhr,
     'xl-accented': swapLocale(lhr, 'en-XL').lhr,
     'error': errorLhr,
+    'single-category': tweakLhrForPsi(lhr),
   };
 
   // Generate and write reports
   Object.entries(filenameToLhr).forEach(([filename, lhr]) => {
-    let html = ReportGenerator.generateReportHtml(lhr);
-    // TODO: PSI is another variant to consider
-    for (const variant of ['', '⌣.cdt.']) {
+    for (const variant of ['', '⌣.cdt.', '⌣.psi.']) {
+      let html = variant.includes('psi') ?
+        generatePsiReportHtml() :
+        ReportGenerator.generateReportHtml(lhr);
+
       if (variant.includes('cdt')) {
         // TODO: Make the DevTools Audits panel "emulation" more comprehensive
         // - the parent widget/vbox container with overflow
         // - a more constrained/realistic default size
         html = html.replace(`"lh-root lh-vars"`, `"lh-root lh-vars lh-devtools"`);
       }
+
       const filepath = `${DIST}/${variant}${filename}/index.html`;
       fs.mkdirSync(path.dirname(filepath), {recursive: true});
       fs.writeFileSync(filepath, html, {encoding: 'utf-8'});
@@ -50,6 +56,26 @@ const DIST = path.join(LH_ROOT, `dist/now`);
   });
 })();
 
+
+/**
+ * @return {string}
+ */
+function generatePsiReportHtml() {
+  const sanitizedJson = ReportGenerator.sanitizeJson(tweakLhrForPsi(lhr));
+  const PSI_TEMPLATE = fs.readFileSync(
+    `${LH_ROOT}/report/test-assets/faux-psi-template.html`, 'utf8');
+  const PSI_JAVASCRIPT = `
+${fs.readFileSync(`${LH_ROOT}/dist/report/psi.js`, 'utf8')};
+${fs.readFileSync(`${LH_ROOT}/report/test-assets/faux-psi.js`, 'utf8')};
+  `;
+
+  const html = ReportGenerator.replaceStrings(PSI_TEMPLATE, [
+    {search: '%%LIGHTHOUSE_JSON%%', replacement: sanitizedJson},
+    {search: '%%LIGHTHOUSE_JAVASCRIPT%%', replacement: PSI_JAVASCRIPT},
+    {search: '/*%%LIGHTHOUSE_CSS%%*/', replacement: htmlReportAssets.REPORT_CSS},
+  ]);
+  return html;
+}
 /**
  * Add a plugin to demo plugin rendering.
  * @param {LH.Result} lhr
@@ -63,6 +89,24 @@ function addPluginCategory(lhr) {
   };
 }
 
+/**
+ * Drops the LHR to only one, solo category (performance), and removes budgets.
+ * @param {LH.Result} lhr
+ */
+function tweakLhrForPsi(lhr) {
+  /** @type {LH.Result} */
+  const clone = JSON.parse(JSON.stringify(lhr));
+  clone.categories = {
+    'performance': clone.categories.performance,
+  };
+  // no budgets in PSI
+  delete clone.audits['performance-budget'];
+  clone.categories.performance.auditRefs = clone.categories.performance.auditRefs.filter(audit => {
+    return !audit.id.endsWith('-budget');
+  });
+  return clone;
+}
+
 /**
  * Generate an LHR with errors for the renderer to display.
  * We'll write an "empty" artifacts file to disk, only to use it in auditMode.
diff --git a/build/build-treemap.js b/build/build-treemap.js
index c85a8bd24..63f4b724e 100644
--- a/build/build-treemap.js
+++ b/build/build-treemap.js
@@ -8,7 +8,6 @@
 /** @typedef {import('../lighthouse-core/lib/i18n/locales').LhlMessages} LhlMessages */
 
 const fs = require('fs');
-const {buildTreemapReport} = require('./build-report.js');
 const GhPagesApp = require('./gh-pages-app.js');
 const {LH_ROOT} = require('../root.js');
 
@@ -19,10 +18,9 @@ const {LH_ROOT} = require('../root.js');
  */
 function buildStrings() {
   const locales = require('../lighthouse-core/lib/i18n/locales.js');
-  const UIStrings = require(
-    // Prevent `tsc -p .` from evaluating util.js using core types, it is already typchecked by `tsc -p lighthouse-treemap`.
-    '' + '../lighthouse-treemap/app/src/util.js'
-  ).UIStrings;
+  // TODO(esmodules): use dynamic import when build/ is esm.
+  const utilCode = fs.readFileSync(LH_ROOT + '/lighthouse-treemap/app/src/util.js', 'utf-8');
+  const {UIStrings} = eval(utilCode.replace('export ', '') + '\nmodule.exports = TreemapUtil;');
   const strings = /** @type {Record<LH.Locale, LhlMessages>} */ ({});
 
   for (const [locale, lhlMessages] of Object.entries(locales)) {
@@ -46,8 +44,6 @@ function buildStrings() {
  * Build treemap app, optionally deploying to gh-pages if `--deploy` flag was set.
  */
 async function run() {
-  await buildTreemapReport();
-
   const app = new GhPagesApp({
     name: 'treemap',
     appDir: `${LH_ROOT}/lighthouse-treemap/app`,
@@ -68,9 +64,7 @@ async function run() {
       fs.readFileSync(require.resolve('pako/dist/pako_inflate.js'), 'utf-8'),
       /* eslint-enable max-len */
       buildStrings(),
-      {path: '../../lighthouse-viewer/app/src/deps-for-treemap.js', rollup: true},
-      {path: '../../dist/report/treemap.js'},
-      {path: 'src/**/*'},
+      {path: 'src/main.js', rollup: true},
     ],
     assets: [
       {path: 'images/**/*'},
diff --git a/lighthouse-core/lib/i18n/README.md b/lighthouse-core/lib/i18n/README.md
index d36888202..523bad7a0 100644
--- a/lighthouse-core/lib/i18n/README.md
+++ b/lighthouse-core/lib/i18n/README.md
@@ -39,7 +39,7 @@ To a typical developer, the pipeline looks like this:
 $ yarn i18n:collect-strings
 
 # Test to see that the new translations are valid and apply to all strings
-$ node lighthouse-core/scripts/build-report-for-autodeployment.js && open dist/xl-accented/index.html
+$ yarn build-sample-reports && open dist/xl-accented/index.html
 ```
 
 Note: Why do `en-US` and `en-XL` get baked early?  We write all our strings in `en-US` by default, so they do not need to be translated, so it can be immediately baked without going to the translators.  Similarly, `en-XL` is a debugging language, it is an automated version of `en-US` that simply adds markers to `en` strings in order to make it obvious that something has or hasn't been translated.  So neither of these files need to go to translators to be used, and both can be used at develop-time to help developer i18n workflow.
diff --git a/lighthouse-core/scripts/dogfood-lhci.sh b/lighthouse-core/scripts/dogfood-lhci.sh
index 226aff692..f3b3eb4bf 100755
--- a/lighthouse-core/scripts/dogfood-lhci.sh
+++ b/lighthouse-core/scripts/dogfood-lhci.sh
@@ -37,7 +37,7 @@ fi
 
 # Generate HTML reports in ./dist/now/
 yarn build-report
-yarn now-build
+yarn vercel-build
 
 # Install LHCI
 npm install -g @lhci/cli@next
diff --git a/lighthouse-treemap/app/src/main.js b/lighthouse-treemap/app/src/main.js
index 602c92ba1..dc9c12b67 100644
--- a/lighthouse-treemap/app/src/main.js
+++ b/lighthouse-treemap/app/src/main.js
@@ -9,7 +9,14 @@
 
 /* eslint-env browser */
 
-/* globals I18n webtreemap strings TreemapUtil TextEncoding Tabulator Cell Row DragAndDrop Logger GithubApi */
+/* globals webtreemap strings Tabulator Cell Row */
+
+import {TreemapUtil} from './util.js';
+import {DragAndDrop} from '../../../lighthouse-viewer/app/src/drag-and-drop.js';
+import {GithubApi} from '../../../lighthouse-viewer/app/src/github-api.js';
+import {I18n} from '../../../report/renderer/i18n.js';
+import {TextEncoding} from '../../../report/renderer/text-encoding.js';
+import {Logger} from '../../../report/renderer/logger.js';
 
 const DUPLICATED_MODULES_IGNORE_THRESHOLD = 1024;
 const DUPLICATED_MODULES_IGNORE_ROOT_RATIO = 0.01;
@@ -707,7 +714,7 @@ function injectOptions(options) {
  * @param {LhlMessages} localeMessages
  */
 function getStrings(localeMessages) {
-  const strings = /** @type {TreemapUtil['UIStrings']} */ ({});
+  const strings = /** @type {typeof TreemapUtil['UIStrings']} */ ({});
 
   for (const varName of Object.keys(localeMessages)) {
     const key = /** @type {keyof typeof TreemapUtil['UIStrings']} */ (varName);
@@ -777,7 +784,7 @@ class LighthouseTreemap {
     for (const node of document.querySelectorAll('[data-i18n]')) {
       // These strings are guaranteed to (at least) have a default English string in TreemapUtil.UIStrings,
       // so this cannot be undefined as long as `report-ui-features.data-i18n` test passes.
-      const i18nAttr = /** @type {keyof TreemapUtil['UIStrings']} */ (
+      const i18nAttr = /** @type {keyof typeof TreemapUtil['UIStrings']} */ (
         node.getAttribute('data-i18n'));
       node.textContent = TreemapUtil.i18n.strings[i18nAttr];
     }
diff --git a/lighthouse-treemap/app/src/util.js b/lighthouse-treemap/app/src/util.js
index 0e07dc17e..e89f21bd3 100644
--- a/lighthouse-treemap/app/src/util.js
+++ b/lighthouse-treemap/app/src/util.js
@@ -11,7 +11,30 @@
 /** @template {string} T @typedef {import('typed-query-selector/parser').ParseSelector<T, Element>} ParseSelector */
 /** @template T @typedef {import('../../../report/renderer/i18n').I18n<T>} I18n */
 
-class TreemapUtil {
+export class TreemapUtil {
+  /** @type {I18n<typeof TreemapUtil['UIStrings']>} */
+  // @ts-expect-error: Is set in main.
+  static i18n = null;
+
+  static UIStrings = {
+    /** Label for a button that alternates between showing or hiding a table. */
+    toggleTableButtonLabel: 'Toggle Table',
+    /** Text for an option in a dropdown menu. When selected, the app shows information for all scripts that were found in a web page. */
+    allScriptsDropdownLabel: 'All Scripts',
+    /** Label for a table column where the values are URLs, JS module names, or arbitrary identifiers. For simplicity, just 'name' is used. */
+    tableColumnName: 'Name',
+    /** Label for column giving the size of a file in bytes. */
+    resourceBytesLabel: 'Resource Bytes',
+    /** Label for a value associated with how many bytes of a script are not executed. */
+    unusedBytesLabel: 'Unused Bytes',
+    /** Label for a column where the values represent how much of a file is used bytes vs unused bytes (coverage). */
+    coverageColumnName: 'Coverage',
+    /** Label for a button that shows everything (or rather, does not highlight any specific mode such as: unused bytes, duplicate bytes, etc). */
+    allLabel: 'All',
+    /** Label for a button that highlights information about duplicate modules (aka: files, javascript resources that were included twice by a web page). */
+    duplicateModulesLabel: 'Duplicate Modules',
+  };
+
   /**
    * @param {LH.Treemap.Node} node
    * @param {(node: NodeWithElement, path: string[]) => void} fn
@@ -191,33 +214,3 @@ TreemapUtil.COLOR_HUES = [
   15.9,
   199.5,
 ];
-
-/** @type {I18n<typeof TreemapUtil['UIStrings']>} */
-// @ts-expect-error: Is set in main.
-TreemapUtil.i18n = null;
-
-TreemapUtil.UIStrings = {
-  /** Label for a button that alternates between showing or hiding a table. */
-  toggleTableButtonLabel: 'Toggle Table',
-  /** Text for an option in a dropdown menu. When selected, the app shows information for all scripts that were found in a web page. */
-  allScriptsDropdownLabel: 'All Scripts',
-  /** Label for a table column where the values are URLs, JS module names, or arbitrary identifiers. For simplicity, just 'name' is used. */
-  tableColumnName: 'Name',
-  /** Label for column giving the size of a file in bytes. */
-  resourceBytesLabel: 'Resource Bytes',
-  /** Label for a value associated with how many bytes of a script are not executed. */
-  unusedBytesLabel: 'Unused Bytes',
-  /** Label for a column where the values represent how much of a file is used bytes vs unused bytes (coverage). */
-  coverageColumnName: 'Coverage',
-  /** Label for a button that shows everything (or rather, does not highlight any specific mode such as: unused bytes, duplicate bytes, etc). */
-  allLabel: 'All',
-  /** Label for a button that highlights information about duplicate modules (aka: files, javascript resources that were included twice by a web page). */
-  duplicateModulesLabel: 'Duplicate Modules',
-};
-
-// node export for testing.
-if (typeof module !== 'undefined' && module.exports) {
-  module.exports = TreemapUtil;
-} else {
-  self.TreemapUtil = TreemapUtil;
-}
diff --git a/lighthouse-treemap/package.json b/lighthouse-treemap/package.json
new file mode 100644
index 000000000..bd3462847
--- /dev/null
+++ b/lighthouse-treemap/package.json
@@ -0,0 +1,4 @@
+{
+  "type": "module",
+  "//": "Any directory that uses `import ... from` or `export ...` must be type module. Temporary file until root package.json is type: module"
+}
\ No newline at end of file
diff --git a/lighthouse-treemap/types/treemap.d.ts b/lighthouse-treemap/types/treemap.d.ts
index a2219afe8..94be41f9f 100644
--- a/lighthouse-treemap/types/treemap.d.ts
+++ b/lighthouse-treemap/types/treemap.d.ts
@@ -1,11 +1,4 @@
-import _TreemapUtil = require('../app/src/util.js');
-import {DragAndDrop as _DragAndDrop} from '../../lighthouse-viewer/app/src/drag-and-drop.js';
-import {FirebaseAuth as _FirebaseAuth} from '../../lighthouse-viewer/app/src/firebase-auth.js';
-import {GithubApi as _GithubApi} from '../../lighthouse-viewer/app/src/github-api.js';
-import {TextEncoding as _TextEncoding} from '../../report/renderer/text-encoding.js';
 import {Logger as _Logger} from '../../report/renderer/logger.js';
-import {I18n as _I18n} from '../../report/renderer/i18n.js';
-import {getFilenamePrefix as _getFilenamePrefix} from '../../report/renderer/file-namer.js';
 import {FirebaseNamespace} from '@firebase/app-types';
 
 declare global {
@@ -38,17 +31,10 @@ declare global {
     render(el: HTMLElement, data: any, options: WebTreeMapOptions): void;
     sort(data: any): void;
   };
-  var TreemapUtil: typeof _TreemapUtil;
-  var TextEncoding: typeof _TextEncoding;
-  var Logger: typeof _Logger;
-  var DragAndDrop: typeof _DragAndDrop;
-  var GithubApi: typeof _GithubApi;
-  var FirebaseAuth: typeof _FirebaseAuth;
+  var logger: _Logger;
   var firebase: Required<FirebaseNamespace>;
   var idbKeyval: typeof import('idb-keyval');
   var strings: Record<LH.Locale, import('../../lighthouse-core/lib/i18n/locales').LhlMessages>;
-  var getFilenamePrefix: typeof _getFilenamePrefix;
-  var I18n: typeof _I18n;
 
   interface Window {
     logger: _Logger;
diff --git a/lighthouse-viewer/app/index.html b/lighthouse-viewer/app/index.html
index 19f6ecbc5..7a2aa11dc 100644
--- a/lighthouse-viewer/app/index.html
+++ b/lighthouse-viewer/app/index.html
@@ -16,7 +16,6 @@ Unless required by applicable law or agreed to in writing, software distributed
   <link rel="stylesheet" href="styles/bundled.css">
 </head>
 <body class="lh-root lh-vars">
-<div hidden>%%LIGHTHOUSE_TEMPLATES%%</div>
 
 <div class="drop_zone"></div>
 
diff --git a/lighthouse-viewer/app/src/deps-for-treemap.js b/lighthouse-viewer/app/src/deps-for-treemap.js
deleted file mode 100644
index 56b6dace8..000000000
--- a/lighthouse-viewer/app/src/deps-for-treemap.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @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';
-
-// TODO(esmodules): remove when treemap is esm.
-
-import {GithubApi} from './github-api.js';
-import {FirebaseAuth} from './firebase-auth.js';
-import {DragAndDrop} from './drag-and-drop.js';
-
-// @ts-expect-error
-window.GithubApi = GithubApi;
-// @ts-expect-error
-window.FirebaseAuth = FirebaseAuth;
-// @ts-expect-error
-window.DragAndDrop = DragAndDrop;
diff --git a/package.json b/package.json
index 1af5db1d7..3e91b233d 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
     "build-lr": "yarn reset-link && node ./build/build-lightrider-bundles.js",
     "build-pack": "bash build/build-pack.sh",
     "build-report": "node build/build-report-components.js && yarn eslint --fix report/renderer/components.js && node build/build-report.js",
+    "build-sample-reports": "yarn build-report && node build/build-sample-reports.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",
@@ -32,7 +33,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": "yarn build-report --only-standalone && node ./lighthouse-cli/index.js",
+    "start": "yarn build-report --standalone && node ./lighthouse-cli/index.js",
     "jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.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",
@@ -46,7 +47,7 @@
     "unit-core": "yarn jest \"lighthouse-core\"",
     "unit-cli": "yarn jest \"lighthouse-cli/\"",
     "unit-report": "yarn jest \"report/\"",
-    "unit-treemap": "jest \"lighthouse-treemap/.*-test.js\"",
+    "unit-treemap": "yarn jest \"lighthouse-treemap/.*-test.js\"",
     "unit-viewer": "yarn jest \"lighthouse-viewer/.*-test.js\"",
     "unit": "yarn unit-core && yarn unit-cli && yarn unit-report && yarn unit-viewer && yarn unit-treemap",
     "unit:ci": "NODE_OPTIONS=--max-old-space-size=8192 npm run unit-core -- --ci && npm run unit-cli -- --ci && npm run unit-report -- --ci && npm run unit-viewer -- --ci && npm run unit-treemap -- --ci",
@@ -62,7 +63,7 @@
     "fast": "node ./lighthouse-cli/index.js --preset=desktop --throttlingMethod=provided",
     "deploy-treemap": "yarn build-treemap --deploy",
     "deploy-viewer": "yarn build-viewer --deploy",
-    "now-build": "yarn build-report && node lighthouse-core/scripts/build-report-for-autodeployment.js && yarn build-viewer && yarn build-treemap && cp -r dist/gh-pages dist/now/gh-pages",
+    "vercel-build": "yarn build-sample-reports && yarn build-viewer && yarn build-treemap && cp -r dist/gh-pages dist/now/gh-pages",
     "dogfood-lhci": "./lighthouse-core/scripts/dogfood-lhci.sh",
     "timing-trace": "node lighthouse-core/scripts/generate-timing-trace.js",
     "changelog": "conventional-changelog --config ./build/changelog-generator/index.js --infile changelog.md --same-file",
diff --git a/report/assets/standalone-template.html b/report/assets/standalone-template.html
index cca9e9913..5a64103a4 100644
--- a/report/assets/standalone-template.html
+++ b/report/assets/standalone-template.html
@@ -25,7 +25,6 @@ limitations under the License.
 </head>
 <body class="lh-root lh-vars">
   <noscript>Lighthouse report requires JavaScript. Please enable.</noscript>
-  <div hidden>%%LIGHTHOUSE_TEMPLATES%%</div>
 
   <main><!-- report populated here --></main>
 
diff --git a/report/clients/psi.js b/report/clients/psi.js
index fbeabce90..29d48b5f3 100644
--- a/report/clients/psi.js
+++ b/report/clients/psi.js
@@ -24,6 +24,10 @@ import {PerformanceCategoryRenderer} from '../renderer/performance-category-rend
 import {ReportUIFeatures} from '../renderer/report-ui-features.js';
 import {Util} from '../renderer/util.js';
 
+/* global window */
+
+/** @typedef {{scoreGaugeEl: Element, perfCategoryEl: Element, finalScreenshotDataUri: string|null, scoreScaleEl: Element, installFeatures: Function}} PrepareLabDataResult */
+
 /**
  * Returns all the elements that PSI needs to render the report
  * We expose this helper method to minimize the 'public' API surface of the renderer
@@ -36,7 +40,7 @@ import {Util} from '../renderer/util.js';
  *
  * @param {LH.Result | string} LHResult The stringified version of {LH.Result}
  * @param {Document} document The host page's window.document
- * @return {{scoreGaugeEl: Element, perfCategoryEl: Element, finalScreenshotDataUri: string|null, scoreScaleEl: Element, installFeatures: Function}}
+ * @return {PrepareLabDataResult}
  */
 export function prepareLabData(LHResult, document) {
   const lhResult = (typeof LHResult === 'string') ?
@@ -89,10 +93,11 @@ export function prepareLabData(LHResult, document) {
   /** @param {HTMLElement} reportEl */
   const installFeatures = (reportEl) => {
     if (fullPageScreenshot) {
+      // 1) Add fpss css var to reportEl parent so any thumbnails will work
       ElementScreenshotRenderer.installFullPageScreenshot(
         reportEl, fullPageScreenshot.screenshot);
 
-      // Append the overlay element to a specific part of the DOM so that
+      // 2) Append the overlay element to a specific part of the DOM so that
       // the sticky tab group element renders correctly. If put in the reportEl
       // like normal, then the sticky header would bleed through the overlay
       // element.
@@ -141,3 +146,9 @@ function _getFinalScreenshot(perfCategory) {
   if (!details || details.type !== 'screenshot') return null;
   return details.data;
 }
+
+// TODO: remove with report API refactor.
+if (typeof window !== 'undefined') {
+  // @ts-expect-error
+  window.prepareLabData = prepareLabData;
+}
diff --git a/report/clients/treemap.js b/report/clients/treemap.js
deleted file mode 100644
index 896a4752f..000000000
--- a/report/clients/treemap.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @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 window */
-
-// TODO(esmodules): delete when treemap app is esm.
-
-import {I18n} from '../renderer/i18n.js';
-import {Logger} from '../renderer/logger.js';
-import {TextEncoding} from '../renderer/text-encoding.js';
-
-// @ts-expect-error
-window.I18n = I18n;
-// @ts-expect-error
-window.Logger = Logger;
-// @ts-expect-error
-window.TextEncoding = TextEncoding;
diff --git a/report/report-generator.js b/report/report-generator.js
index 05031d274..ff84a0c58 100644
--- a/report/report-generator.js
+++ b/report/report-generator.js
@@ -28,15 +28,23 @@ class ReportGenerator {
   }
 
   /**
-   * Returns the report HTML as a string with the report JSON and renderer JS inlined.
+   * @param {unknown} object
+   * @return {string}
+   */
+  static sanitizeJson(object) {
+    return JSON.stringify(object)
+    .replace(/</g, '\\u003c') // replaces opening script tags
+    .replace(/\u2028/g, '\\u2028') // replaces line separators ()
+    .replace(/\u2029/g, '\\u2029'); // replaces paragraph separators
+  }
+
+  /**
+   * Returns the standalone report HTML as a string with the report JSON and renderer JS inlined.
    * @param {LH.Result} lhr
    * @return {string}
    */
   static generateReportHtml(lhr) {
-    const sanitizedJson = JSON.stringify(lhr)
-      .replace(/</g, '\\u003c') // replaces opening script tags
-      .replace(/\u2028/g, '\\u2028') // replaces line separators ()
-      .replace(/\u2029/g, '\\u2029'); // replaces paragraph separators
+    const sanitizedJson = ReportGenerator.sanitizeJson(lhr);
     // terser does its own sanitization, but keep this basic replace for when
     // we want to generate a report without minification.
     const sanitizedJavascript = htmlReportAssets.REPORT_JAVASCRIPT.replace(/<\//g, '\\u003c/');


Test patch:
diff --git a/lighthouse-treemap/test/treemap-test-pptr.js b/lighthouse-treemap/test/treemap-test-pptr.js
index 0fe5e6330..a6a517e92 100644
--- a/lighthouse-treemap/test/treemap-test-pptr.js
+++ b/lighthouse-treemap/test/treemap-test-pptr.js
@@ -9,19 +9,24 @@
 
 /* global document, window */
 
-const fs = require('fs');
-const puppeteer = require('puppeteer');
-const {server} = require('../../lighthouse-cli/test/fixtures/static-server.js');
+import {jest} from '@jest/globals';
+import fs from 'fs';
+import puppeteer from 'puppeteer';
+import {server} from '../../lighthouse-cli/test/fixtures/static-server.js';
+import {LH_ROOT} from '../../root.js';
+
+const debugOptions = JSON.parse(
+  fs.readFileSync(LH_ROOT + '/lighthouse-treemap/app/debug.json', 'utf-8')
+);
 const portNumber = 20202;
 const treemapUrl = `http://localhost:${portNumber}/dist/gh-pages/treemap/index.html`;
-const debugOptions = require('../app/debug.json');
 
 // These tests run in Chromium and have their own timeouts.
 // Make sure we get the more helpful test-specific timeout error instead of jest's generic one.
 jest.setTimeout(35_000);
 
 function getTextEncodingCode() {
-  const code = fs.readFileSync(require.resolve('../../report/renderer/text-encoding.js'), 'utf-8');
+  const code = fs.readFileSync(LH_ROOT + '/report/renderer/text-encoding.js', 'utf-8');
   return code.replace('export ', '');
 }
 
diff --git a/lighthouse-treemap/test/util-test.js b/lighthouse-treemap/test/util-test.js
index dd7715d1c..2bd90c53a 100644
--- a/lighthouse-treemap/test/util-test.js
+++ b/lighthouse-treemap/test/util-test.js
@@ -7,10 +7,11 @@
 
 /* eslint-env jest */
 
-const assert = require('assert');
-const fs = require('fs');
-const jsdom = require('jsdom');
-const TreemapUtil = require('../app/src/util.js');
+import assert from 'assert';
+import fs from 'fs';
+import jsdom from 'jsdom';
+import {TreemapUtil} from '../app/src/util.js';
+import {LH_ROOT} from '../../root.js';
 
 describe('TreemapUtil', () => {
   it('pathsAreEqual works', () => {
@@ -75,7 +76,7 @@ describe('TreemapUtil', () => {
 
   describe('data-i18n', () => {
     it('should have only valid data-i18n values in treemap html', () => {
-      const TREEMAP_INDEX = fs.readFileSync(__dirname + '/../app/index.html', 'utf8');
+      const TREEMAP_INDEX = fs.readFileSync(LH_ROOT + '/lighthouse-treemap/app/index.html', 'utf8');
       const dom = new jsdom.JSDOM(TREEMAP_INDEX);
       for (const node of dom.window.document.querySelectorAll('[data-i18n]')) {
         const val = node.getAttribute('data-i18n');
diff --git a/report/test-assets/faux-psi-template.html b/report/test-assets/faux-psi-template.html
new file mode 100644
index 000000000..85a1aa4a5
--- /dev/null
+++ b/report/test-assets/faux-psi-template.html
@@ -0,0 +1,126 @@
+<!--
+ * 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.
+-->
+
+<!--
+  This fake psi page is intended to reproduce the experience of rendering two LHRs within the same DOM,
+  like in PageSpeed Insights. This helps surface the peculiar behavior around tabs,
+  expectations of unique IDs, and scoped report containment.
+-->
+
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+  <title>Faux PageSpeed Report</title>
+  <style>/*%%LIGHTHOUSE_CSS%%*/</style>
+
+  <style>
+/* Inspired by https://codepen.io/markcaron/pen/MvGRYV?editors=1100 but improved. s*/
+/*
+ The tab visibility basics
+*/
+.tabset > input[type="radio"] {
+  position: absolute;
+  left: -200vw;
+}
+
+.tabset .tab-panel {
+  display: none;
+}
+
+.tabset > input:nth-of-type(1):checked ~ .tab-panels > .tab-panel:nth-of-type(1),
+.tabset > input:nth-of-type(2):checked ~ .tab-panels > .tab-panel:nth-of-type(2) {
+  display: block;
+}
+
+/*
+ Purdy tabs
+*/
+.tabset > label {
+  position: relative;
+  display: inline-block;
+  padding: 15px 55px 15px;
+  border: 1px solid transparent;
+  border-bottom: 0;
+  cursor: pointer;
+  font-weight: 600;
+}
+
+.tabset > label:hover {
+  background-color: hsl(0deg 0% 100% / 24%);
+}
+
+.tabset > label:hover,
+.tabset > input:focus + label {
+  color: #06c;
+}
+
+.tabset > input:checked + label {
+  border-color: #ccc;
+  border-bottom: 1px solid #fff;
+  margin-bottom: -1px;
+  background: white;
+}
+
+.tab-panel {
+  padding: 30px 0;
+  border-top: 1px solid #ccc;
+}
+
+body {
+  padding: 30px;
+}
+
+.tabset {
+  max-width: 65em;
+  background: #eee;
+}
+
+.tab-panels {
+  background: white;
+}
+
+/* extra styles */
+body {
+  background: hsl(217deg 89% 61%);
+}
+  </style>
+</head>
+<body >
+  <noscript>PSI requires JavaScript. Please enable.</noscript>
+
+  <div class="element-screenshots-container"></div>
+
+
+  <div class="tabset lh-vars lh-root">
+
+    <input type="radio" name="tabset" id="tab1" aria-controls="mobile" checked>
+    <label for="tab1">Mobile</label>
+
+    <input type="radio" name="tabset" id="tab2" aria-controls="desktop">
+    <label for="tab2">Desktop</label>
+
+    <div class="tab-panels">
+      <section id="mobile" class="tab-panel">
+        <main></main>
+      </section>
+
+      <section id="desktop" class="tab-panel">
+        <main></main>
+      </section>
+
+    </div>
+  </div>
+
+  <script>window.__LIGHTHOUSE_JSON__ = %%LIGHTHOUSE_JSON%%;</script>
+  <script type="module">
+%%LIGHTHOUSE_JAVASCRIPT%%
+  //# sourceURL=psi-reportrenderer.js
+  </script>
+  <script>console.log('window.__LIGHTHOUSE_JSON__', __LIGHTHOUSE_JSON__);</script>
+</body>
+</html>
diff --git a/report/test-assets/faux-psi.js b/report/test-assets/faux-psi.js
new file mode 100644
index 000000000..8bf0093eb
--- /dev/null
+++ b/report/test-assets/faux-psi.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.
+ */
+'use strict';
+
+/** @fileoverview This file is a glorified call of prepareLabData. */
+
+/* global document window */
+
+/** @typedef {import('../clients/psi.js').PrepareLabDataResult} PrepareLabDataResult */
+
+(async function __initPsiReports__() {
+  // @ts-expect-error
+  const mobileLHR = window.__LIGHTHOUSE_JSON__;
+  const desktopLHR = JSON.parse(JSON.stringify(mobileLHR));
+
+  const lhrs = {
+    'mobile': mobileLHR,
+    'desktop': desktopLHR,
+  };
+
+  for (const [tabId, lhr] of Object.entries(lhrs)) {
+    await distinguishLHR(lhr, tabId);
+
+    // @ts-expect-error
+    const pldd = /** @type {PrepareLabDataResult} */ (window.prepareLabData(lhr, document));
+    const {scoreGaugeEl, perfCategoryEl,
+      finalScreenshotDataUri, scoreScaleEl, installFeatures} = pldd;
+
+    const container = document.querySelector(`#${tabId} main`);
+    if (!container) throw new Error('Unexpected DOM. Bailing.');
+    container.append(scoreGaugeEl);
+    container.append(scoreScaleEl);
+    if (finalScreenshotDataUri) {
+      const imgEl = document.createElement('img');
+      imgEl.src = finalScreenshotDataUri;
+      container.append(imgEl);
+    }
+    container.append(perfCategoryEl);
+    installFeatures(container);
+  }
+})();
+
+
+/**
+ * Tweak the LHR to make the desktop and mobile reports easier to identify.
+ * Adjusted: Perf category name and score, and emoji placed on top of key screenshots.
+ * @param {LH.Result} lhr
+ * @param {string} tabId
+ */
+async function distinguishLHR(lhr, tabId) {
+  lhr.categories.performance.title += ` ${tabId}`; // for easier identification
+  if (tabId === 'desktop') {
+    lhr.categories.performance.score = 0.81;
+  }
+
+  const finalSSDetails = lhr.audits['final-screenshot'] && lhr.audits['final-screenshot'].details;
+  if (!finalSSDetails || finalSSDetails.type !== 'screenshot') throw new Error();
+  finalSSDetails.data = await decorateScreenshot(finalSSDetails.data, tabId);
+
+  const fpSSDetails = lhr.audits['full-page-screenshot'] &&
+      lhr.audits['full-page-screenshot'].details;
+  if (!fpSSDetails || fpSSDetails.type !== 'full-page-screenshot') throw new Error();
+  fpSSDetails.screenshot.data = await decorateScreenshot(fpSSDetails.screenshot.data, tabId);
+}
+
+/**
+ * Add 📱 and 💻 emoji on top of screenshot
+ * @param {string} datauri
+ * @param {string} tabId
+ */
+async function decorateScreenshot(datauri, tabId) {
+  const img = document.createElement('img');
+
+  await new Promise((resolve, reject) => {
+    img.addEventListener('load', () => resolve(img));
+    img.addEventListener('error', (err) => reject(err));
+    img.src = datauri;
+  });
+  const c = document.createElement('canvas');
+  c.width = img.width;
+  c.height = img.height;
+
+  const ctx = c.getContext('2d');
+  if (!ctx) throw new Error();
+  ctx.drawImage(img, 0, 0);
+  ctx.font = `${img.width / 2}px serif`;
+  ctx.textAlign = 'center';
+  ctx.globalAlpha = 0.7;
+  ctx.fillText(tabId === 'mobile' ? '📱' : '💻', img.width / 2, Math.min(img.height / 2, 700));
+  return c.toDataURL();
+}
diff --git a/report/test/clients/psi-test.js b/report/test/clients/psi-test.js
index 9539f25fc..72ff6c69b 100644
--- a/report/test/clients/psi-test.js
+++ b/report/test/clients/psi-test.js
@@ -27,7 +27,7 @@ const sampleResultsStr =
 
 /* eslint-env jest */
 
-describe('DOM', () => {
+describe('PSI', () => {
   let document;
   beforeAll(() => {
     global.Util = Util;