Repository: GoogleChrome/lighthouse
Pull request #12846 (2021-08-02 22:38:11)

clients(viewer): add disclaimer regarding devtools bug
See #12841


Patch:
diff --git a/.eslintignore b/.eslintignore
index 451eba6a3..1d3d4b8a6 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,6 +1,5 @@
 **/node_modules/**
 **/third_party/**
-**/generated/**
 **/source-maps/**
 
 lighthouse-cli/test/fixtures/byte-efficiency/bundle.js
@@ -11,6 +10,7 @@ coverage/**
 
 !.eslintrc.js
 
+lighthouse-core/lib/cdt/generated/SourceMap.js
 lighthouse-core/scripts/legacy-javascript/variants
 
 third-party/**
diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
index 87bb4362f..8db90bf53 100644
--- a/.github/workflows/smoke.yml
+++ b/.github/workflows/smoke.yml
@@ -88,6 +88,29 @@ jobs:
     # Fail if any changes were written to source files.
     - run: git diff --exit-code
 
+  smoke-fr:
+    runs-on: ubuntu-latest
+    name: Fraggle Rock
+
+    steps:
+    - name: git clone
+      uses: actions/checkout@v2
+
+    - name: Use Node.js 12.x
+      uses: actions/setup-node@v1
+      with:
+        node-version: 12.x
+
+    - run: yarn install --frozen-lockfile --network-timeout 1000000
+    - run: yarn build-report
+
+    - run: sudo apt-get install xvfb
+    - name: yarn smoke --fraggle-rock
+      run: xvfb-run --auto-servernum yarn smoke --debug --fraggle-rock -j=1 --retries=2 a11y lantern seo-passing seo-failing csp
+
+    # Fail if any changes were written to source files.
+    - run: git diff --exit-code
+
   smoke-bundle:
     runs-on: ubuntu-latest
     name: Bundled Lighthouse
diff --git a/.gitignore b/.gitignore
index d1d204a1d..25096b003 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,8 +26,6 @@ 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/build/build-dt-report-resources.js b/build/build-dt-report-resources.js
index 9f1e9d853..2ece8729a 100644
--- a/build/build-dt-report-resources.js
+++ b/build/build-dt-report-resources.js
@@ -26,8 +26,9 @@ function writeFile(name, content) {
   fs.writeFileSync(`${distDir}/${name}`, content);
 }
 
+fs.mkdirSync(distDir, {recursive: true}); // Ensure dist is present, else rmdir will throw. COMPAT: when dropping Node 12, replace with fs.rm(p, {force: true})
 fs.rmdirSync(distDir, {recursive: true});
-fs.mkdirSync(distDir);
+fs.mkdirSync(distDir, {recursive: true});
 
 writeFile('report.js', htmlReportAssets.REPORT_JAVASCRIPT);
 writeFile('report.css', htmlReportAssets.REPORT_CSS);
diff --git a/build/gh-pages-app.js b/build/gh-pages-app.js
index e9c5931a6..06268cc5a 100644
--- a/build/gh-pages-app.js
+++ b/build/gh-pages-app.js
@@ -80,6 +80,7 @@ class GhPagesApp {
   }
 
   async build() {
+    fs.mkdirSync(this.distDir, {recursive: true}); // Ensure dist is present, else rmdir will throw. COMPAT: when dropping Node 12, replace with fs.rm(p, {force: true})
     fs.rmdirSync(this.distDir, {recursive: true});
 
     const html = this._compileHtml();
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 4935e24f1..3b428d994 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -114,11 +114,25 @@ class UnusedBytes extends Audit {
       devtoolsLog,
       settings,
     };
-
     const networkRecords = await NetworkRecords.request(devtoolsLog, context);
+
+    // Requesting load simulator requires non-empty network records.
+    // Timespans are not guaranteed to have any network activity.
+    // There are no bytes to be saved if no bytes were downloaded, so mark N/A if empty.
+    if (!networkRecords.length && gatherContext.gatherMode === 'timespan') {
+      return {
+        score: 1,
+        notApplicable: true,
+      };
+    }
+
     const [result, graph, simulator] = await Promise.all([
       this.audit_(artifacts, networkRecords, context),
-      PageDependencyGraph.request({trace, devtoolsLog}, context),
+      // Page dependency graph is only used in navigation mode.
+      // TODO(FR-COMPAT): Use dependency graph in timespan mode.
+      gatherContext.gatherMode === 'navigation' ?
+        PageDependencyGraph.request({trace, devtoolsLog}, context) :
+        null,
       LoadSimulator.request(simulatorOptions, context),
     ]);
 
@@ -200,7 +214,7 @@ class UnusedBytes extends Audit {
 
   /**
    * @param {ByteEfficiencyProduct} result
-   * @param {Node} graph
+   * @param {Node|null} graph
    * @param {Simulator} simulator
    * @param {LH.Artifacts['GatherContext']} gatherContext
    * @return {LH.Audit.Product}
@@ -209,11 +223,16 @@ class UnusedBytes extends Audit {
     const results = result.items.sort((itemA, itemB) => itemB.wastedBytes - itemA.wastedBytes);
 
     const wastedBytes = results.reduce((sum, item) => sum + item.wastedBytes, 0);
-    const wastedMs = gatherContext.gatherMode === 'navigation' ?
-      this.computeWasteWithTTIGraph(results, graph, simulator, {
+
+    let wastedMs;
+    if (gatherContext.gatherMode === 'navigation') {
+      if (!graph) throw Error('Page dependency graph should always be computed in navigation mode');
+      wastedMs = this.computeWasteWithTTIGraph(results, graph, simulator, {
         providedWastedBytesByUrl: result.wastedBytesByUrl,
-      }) :
-      this.computeWastedMsWithThroughput(wastedBytes, simulator);
+      });
+    } else {
+      wastedMs = this.computeWastedMsWithThroughput(wastedBytes, simulator);
+    }
 
     let displayValue = result.displayValue || '';
     if (typeof result.displayValue === 'undefined' && wastedBytes) {
diff --git a/lighthouse-core/audits/network-rtt.js b/lighthouse-core/audits/network-rtt.js
index 7a789776f..3b35357b1 100644
--- a/lighthouse-core/audits/network-rtt.js
+++ b/lighthouse-core/audits/network-rtt.js
@@ -7,6 +7,7 @@
 
 const Audit = require('./audit.js');
 const i18n = require('../lib/i18n/i18n.js');
+const NetworkRecords = require('../computed/network-records.js');
 const NetworkAnalysisComputed = require('../computed/network-analysis.js');
 
 const UIStrings = {
@@ -41,6 +42,14 @@ class NetworkRTT extends Audit {
    */
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const records = await NetworkRecords.request(devtoolsLog, context);
+    if (!records.length) {
+      return {
+        score: 1,
+        notApplicable: true,
+      };
+    }
+
     const analysis = await NetworkAnalysisComputed.request(devtoolsLog, context);
 
     /** @type {number} */
diff --git a/lighthouse-core/audits/network-server-latency.js b/lighthouse-core/audits/network-server-latency.js
index 9fac7d8ae..f7a849778 100644
--- a/lighthouse-core/audits/network-server-latency.js
+++ b/lighthouse-core/audits/network-server-latency.js
@@ -7,6 +7,7 @@
 
 const Audit = require('./audit.js');
 const i18n = require('../lib/i18n/i18n.js');
+const NetworkRecords = require('../computed/network-records.js');
 const NetworkAnalysisComputed = require('../computed/network-analysis.js');
 
 const UIStrings = {
@@ -41,6 +42,14 @@ class NetworkServerLatency extends Audit {
    */
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const records = await NetworkRecords.request(devtoolsLog, context);
+    if (!records.length) {
+      return {
+        score: 1,
+        notApplicable: true,
+      };
+    }
+
     const analysis = await NetworkAnalysisComputed.request(devtoolsLog, context);
 
     /** @type {number} */
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index 5a31a555c..c2e4aebe8 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -82,9 +82,9 @@ const screenEmulationMetrics = {
 };
 
 // eslint-disable-next-line max-len
-const MOTOG4_USERAGENT = 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse';
+const MOTOG4_USERAGENT = 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Mobile Safari/537.36 Chrome-Lighthouse';
 // eslint-disable-next-line max-len
-const DESKTOP_USERAGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Safari/537.36 Chrome-Lighthouse';
+const DESKTOP_USERAGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse';
 
 const userAgents = {
   mobile: MOTOG4_USERAGENT,
diff --git a/lighthouse-core/fraggle-rock/config/filters.js b/lighthouse-core/fraggle-rock/config/filters.js
index 5af6323fd..657b590ea 100644
--- a/lighthouse-core/fraggle-rock/config/filters.js
+++ b/lighthouse-core/fraggle-rock/config/filters.js
@@ -24,6 +24,10 @@ const baseArtifactKeys = Object.keys(baseArtifactKeySource);
 // Keep these audits unless they are *directly* skipped with `skipAudits`.
 const filterResistantAuditIds = ['full-page-screenshot'];
 
+// Some artifacts are used by the report for additional information.
+// Always run these artifacts even if audits do not request them.
+const filterResistantArtifactIds = ['HostUserAgent', 'HostFormFactor', 'Stacks', 'GatherContext'];
+
 /**
  * Returns the set of audit IDs used in the list of categories.
  * If `onlyCategories` is not set, this function returns the list of all audit IDs across all
@@ -56,9 +60,10 @@ function filterArtifactsByAvailableAudits(artifacts, audits) {
   const artifactsById = new Map(artifacts.map(artifact => [artifact.id, artifact]));
 
   /** @type {Set<string>} */
-  const artifactIdsToKeep = new Set(
-    audits.flatMap(audit => audit.implementation.meta.requiredArtifacts)
-  );
+  const artifactIdsToKeep = new Set([
+    ...filterResistantArtifactIds,
+    ...audits.flatMap(audit => audit.implementation.meta.requiredArtifacts),
+  ]);
 
   // Keep all artifacts in the dependency tree of required artifacts.
   // Iterate through all kept artifacts, adding their dependencies along the way, until the set does not change.
diff --git a/lighthouse-core/gather/gatherers/css-usage.js b/lighthouse-core/gather/gatherers/css-usage.js
index 06c9b4111..766e0c9f2 100644
--- a/lighthouse-core/gather/gatherers/css-usage.js
+++ b/lighthouse-core/gather/gatherers/css-usage.js
@@ -17,6 +17,13 @@ class CSSUsage extends FRGatherer {
     this._stylesheets = [];
     /** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
     this._onStylesheetAdded = sheet => this._stylesheets.push(sheet);
+    /** @param {LH.Crdp.CSS.StyleSheetRemovedEvent} sheet */
+    this._onStylesheetRemoved = sheet => {
+      // We can't fetch the content of removed stylesheets, so we ignore them completely.
+      // TODO(FR-COMPAT): Refactor use of CSSUsage artifact to not *always* rely on the content of stylesheets.
+      const styleSheetId = sheet.styleSheetId;
+      this._stylesheets = this._stylesheets.filter(s => s.header.styleSheetId !== styleSheetId);
+    };
     /**
      * Initialize as undefined so we can assert results are fetched.
      * @type {LH.Crdp.CSS.RuleUsage[]|undefined}
@@ -35,6 +42,7 @@ class CSSUsage extends FRGatherer {
   async startCSSUsageTracking(context) {
     const session = context.driver.defaultSession;
     session.on('CSS.styleSheetAdded', this._onStylesheetAdded);
+    session.on('CSS.styleSheetRemoved', this._onStylesheetRemoved);
 
     await session.sendCommand('DOM.enable');
     await session.sendCommand('CSS.enable');
@@ -57,6 +65,7 @@ class CSSUsage extends FRGatherer {
     const coverageResponse = await session.sendCommand('CSS.stopRuleUsageTracking');
     this._ruleUsage = coverageResponse.ruleUsage;
     session.off('CSS.styleSheetAdded', this._onStylesheetAdded);
+    session.off('CSS.styleSheetRemoved', this._onStylesheetRemoved);
   }
 
   /**
diff --git a/lighthouse-treemap/app/src/main.js b/lighthouse-treemap/app/src/main.js
index b986f4da2..602c92ba1 100644
--- a/lighthouse-treemap/app/src/main.js
+++ b/lighthouse-treemap/app/src/main.js
@@ -15,6 +15,9 @@ const DUPLICATED_MODULES_IGNORE_THRESHOLD = 1024;
 const DUPLICATED_MODULES_IGNORE_ROOT_RATIO = 0.01;
 
 const logEl = document.querySelector('#lh-log');
+if (!logEl) {
+  throw new Error('logger element not found');
+}
 const logger = new Logger(logEl);
 
 /** @type {TreemapViewer} */
diff --git a/lighthouse-treemap/app/styles/treemap.css b/lighthouse-treemap/app/styles/treemap.css
index d8fb3bcb9..f7da6ac9d 100644
--- a/lighthouse-treemap/app/styles/treemap.css
+++ b/lighthouse-treemap/app/styles/treemap.css
@@ -273,7 +273,6 @@ header {
   align-items: center;
   justify-content: center;
   color: #555;
-  font-weight: 300;
 }
 .treemap-placeholder-inner {
   display: flex;
diff --git a/lighthouse-viewer/app/index.html b/lighthouse-viewer/app/index.html
index 18b9882ef..9783c8d12 100644
--- a/lighthouse-viewer/app/index.html
+++ b/lighthouse-viewer/app/index.html
@@ -35,6 +35,19 @@ Unless required by applicable law or agreed to in writing, software distributed
       or <a href="https://web.dev/measure">web.dev/measure</a>.</div>
       <input type="url" class="viewer-placeholder__url js-gist-url"
              placeholder="Enter or paste Gist URL">
+
+      <p class="dt-disclaimer" style="display: none">
+        <b>DevTools users:</b> there is a <a href="https://github.com/GoogleChrome/lighthouse/issues/12841">known bug</a>
+        where "Open in Viewer" does not work<br>
+        from the DevTools Lighthouse panel. This will be fixed in<br>
+        Chrome 94 (around Sep 21). In the meantime, you can<br>
+        select "Copy JSON" from the Lighthouse report and paste the result on this page.
+      </p>
+      <script>
+        if (location.search === '' && document.referrer === '') {
+          document.querySelector('.dt-disclaimer').style.display = '';
+        }
+      </script>
     </div>
   </div>
 </div>
diff --git a/lighthouse-viewer/app/styles/viewer.css b/lighthouse-viewer/app/styles/viewer.css
index 7724e903d..354cbb20a 100644
--- a/lighthouse-viewer/app/styles/viewer.css
+++ b/lighthouse-viewer/app/styles/viewer.css
@@ -48,7 +48,6 @@
   align-items: center;
   justify-content: center;
   color: #555;
-  font-weight: 300;
 }
 .viewer-placeholder-inner {
   display: flex;
diff --git a/readme.md b/readme.md
index 38fd82b41..2287f068c 100644
--- a/readme.md
+++ b/readme.md
@@ -349,6 +349,8 @@ This section details services that have integrated Lighthouse data. If you're wo
 
 * **[Websu](https://websu.io)** - Websu is an open source project to provide Lighthouse-as-a-Service through a simple HTTP REST API. The main features are ability to host and deploy in your own environment and historical Lighthouse report summaries.
 
+* **[DTEKT.IO](https://dtekt.io)** - DTEKT is a website performance and uptime monitoring service. It uses lighthouse to provide visibility into the performance of websites from multiple locations on multiple devices. It offers three months free trial and paid plans.
+
 ## Lighthouse Integrations in non-Web Perf services
 
 * **[PageWatch](https://pagewatch.dev/)** — PageWatch is a tool to find problem pages on your website.  It provides insights into spelling errors, layout issues, slow pages (powered by Lighthouse) and more.  PageWatch is offered via free and paid plans.
diff --git a/report/clients/psi.js b/report/clients/psi.js
index c243f580a..7486401d2 100644
--- a/report/clients/psi.js
+++ b/report/clients/psi.js
@@ -121,9 +121,10 @@ export function prepareLabData(LHResult, document) {
 
     const showTreemapApp =
       lhResult.audits['script-treemap-data'] && lhResult.audits['script-treemap-data'].details;
-    if (showTreemapApp) {
+    const buttonContainer = reportEl.querySelector('.lh-audit-group--metrics');
+    if (showTreemapApp && buttonContainer) {
       reportUIFeatures.addButton({
-        container: reportEl.querySelector('.lh-audit-group--metrics'),
+        container: buttonContainer,
         text: Util.i18n.strings.viewTreemapLabel,
         icon: 'treemap',
         onClick: () => ReportUIFeatures.openTreemap(lhResult),
diff --git a/report/clients/standalone.js b/report/clients/standalone.js
index 2305b4b66..de7c17ce4 100644
--- a/report/clients/standalone.js
+++ b/report/clients/standalone.js
@@ -64,4 +64,5 @@ function __initLighthouseReport__() {
   });
 }
 
+// @ts-expect-error
 window.__initLighthouseReport__ = __initLighthouseReport__;
diff --git a/report/clients/treemap.js b/report/clients/treemap.js
index b12dcf848..896a4752f 100644
--- a/report/clients/treemap.js
+++ b/report/clients/treemap.js
@@ -13,6 +13,9 @@ 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/clients/viewer.js b/report/clients/viewer.js
index 5a017a41a..13b1e9c17 100644
--- a/report/clients/viewer.js
+++ b/report/clients/viewer.js
@@ -15,8 +15,13 @@ import {ReportRenderer} from '../renderer/report-renderer.js';
 import {ReportUIFeatures} from '../renderer/report-ui-features.js';
 import {TextEncoding} from '../renderer/text-encoding.js';
 
+// @ts-expect-error
 window.DOM = DOM;
+// @ts-expect-error
 window.Logger = Logger;
+// @ts-expect-error
 window.ReportRenderer = ReportRenderer;
+// @ts-expect-error
 window.ReportUIFeatures = ReportUIFeatures;
+// @ts-expect-error
 window.TextEncoding = TextEncoding;
diff --git a/report/renderer/performance-category-renderer.js b/report/renderer/performance-category-renderer.js
index cfa372e7f..0db8e27ff 100644
--- a/report/renderer/performance-category-renderer.js
+++ b/report/renderer/performance-category-renderer.js
@@ -311,11 +311,15 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
       ({acronym: 'All'}),
       ...filterableMetrics,
     ]);
+
+    // Form labels need to reference unique IDs, but multiple reports rendered in the same DOM (eg PSI)
+    // would mean ID conflict.  To address this, we 'scope' these radio inputs with a unique suffix.
+    const uniqSuffix = Util.getUniqueSuffix();
     for (const metric of filterChoices) {
-      const elemId = `metric-${metric.acronym}`;
+      const elemId = `metric-${metric.acronym}-${uniqSuffix}`;
       const radioEl = this.dom.createChildOf(metricFilterEl, 'input', 'lh-metricfilter__radio');
       radioEl.type = 'radio';
-      radioEl.name = 'metricsfilter';
+      radioEl.name = `metricsfilter-${uniqSuffix}`;
       radioEl.id = elemId;
 
       const labelEl = this.dom.createChildOf(metricFilterEl, 'label', 'lh-metricfilter__label');
diff --git a/tsconfig.json b/tsconfig.json
index 4780a3266..ef7b3fc63 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,6 +15,7 @@
   "include": [
     "lighthouse-cli/**/*.js",
     "lighthouse-core/**/*.js",
+    "report/**/*.js",
     "clients/**/*.js",
     "build/**/*.js",
     "./types/**/*.d.ts",
@@ -29,8 +30,8 @@
     "lighthouse-cli/test/fixtures/**/*.js",
     "lighthouse-core/scripts/legacy-javascript/variants",
     "lighthouse-core/test/chromium-web-tests/webtests",
-    "report/generated",
     // These test files require further changes before they can be type checked.
+    "report/test/**/*.js",
     "lighthouse-core/test/config/budget-test.js",
     "lighthouse-core/test/config/config-helpers-test.js",
     "lighthouse-core/test/config/config-plugin-test.js",
diff --git a/types/externs.d.ts b/types/externs.d.ts
index bb195a163..03c78d7cc 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -417,7 +417,7 @@ declare global {
 
   // Stricter querySelector/querySelectorAll using typed-query-selector.
   interface ParentNode {
-    querySelector<S extends string>(selector: S): QuerySelectorParse<S>;
+    querySelector<S extends string>(selector: S): QuerySelectorParse<S> | null;
     querySelectorAll<S extends string>(selector: S): NodeListOf<QuerySelectorParse<S>>;
   }
 }
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 4526dd5b0..7b7328dd4 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -43,11 +43,13 @@
     {expectations: Smokehouse.ExpectedRunnerResult | Array<Smokehouse.ExpectedRunnerResult>}
 
   export type LighthouseRunner =
-    (url: string, configJson?: LH.Config.Json, runnerOptions?: {isDebug?: boolean}) => Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>;
+    (url: string, configJson?: LH.Config.Json, runnerOptions?: {isDebug?: boolean; useFraggleRock?: boolean}) => Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>;
 
   export interface SmokehouseOptions {
     /** If true, performs extra logging from the test runs. */
     isDebug?: boolean;
+    /** If true, uses the new Fraggle Rock runner. */
+    useFraggleRock?: boolean;
     /** Manually set the number of jobs to run at once. `1` runs all tests serially. */
     jobs?: number;
     /** The number of times to retry failing tests before accepting. Defaults to 0. */


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index c6956674a..d39e349cc 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1543,7 +1543,7 @@ Object {
     "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
-    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse",
+    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Mobile Safari/537.36 Chrome-Lighthouse",
     "extraHeaders": null,
     "formFactor": "mobile",
     "gatherMode": false,
@@ -1700,7 +1700,7 @@ Object {
     "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
-    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse",
+    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Mobile Safari/537.36 Chrome-Lighthouse",
     "extraHeaders": null,
     "formFactor": "mobile",
     "gatherMode": false,
diff --git a/lighthouse-cli/test/cli/index-test.js b/lighthouse-cli/test/cli/index-test.js
index c8051a241..0a7b0f18b 100644
--- a/lighthouse-cli/test/cli/index-test.js
+++ b/lighthouse-cli/test/cli/index-test.js
@@ -111,7 +111,7 @@ describe('CLI Tests', function() {
       /* eslint-disable max-len */
       expect(emulationSettings).toMatchInlineSnapshot(`
         Object {
-          "emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Safari/537.36 Chrome-Lighthouse",
+          "emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse",
           "formFactor": "desktop",
           "screenEmulation": Object {
             "deviceScaleFactor": 1,
diff --git a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
index 4b5c9070b..f33eed2f8 100644
--- a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
+++ b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
@@ -127,6 +127,11 @@ async function begin() {
         default: false,
         describe: 'Save test artifacts and output verbose logs',
       },
+      'fraggle-rock': {
+        type: 'boolean',
+        default: false,
+        describe: 'Use the new Fraggle Rock runner',
+      },
       'jobs': {
         type: 'number',
         alias: 'j',
@@ -191,7 +196,14 @@ async function begin() {
     }
 
     const prunedTestDefns = pruneExpectedNetworkRequests(testDefns, takeNetworkRequestUrls);
-    const options = {jobs, retries, isDebug: argv.debug, lighthouseRunner, takeNetworkRequestUrls};
+    const options = {
+      jobs,
+      retries,
+      isDebug: argv.debug,
+      useFraggleRock: argv.fraggleRock,
+      lighthouseRunner,
+      takeNetworkRequestUrls,
+    };
 
     isPassing = (await runSmokehouse(prunedTestDefns, options)).success;
   } finally {
diff --git a/lighthouse-cli/test/smokehouse/lighthouse-runners/cli.js b/lighthouse-cli/test/smokehouse/lighthouse-runners/cli.js
index f00ca6beb..45adee7e0 100644
--- a/lighthouse-cli/test/smokehouse/lighthouse-runners/cli.js
+++ b/lighthouse-cli/test/smokehouse/lighthouse-runners/cli.js
@@ -27,27 +27,42 @@ const ChildProcessError = require('../lib/child-process-error.js');
  * Launch Chrome and do a full Lighthouse run via the Lighthouse CLI.
  * @param {string} url
  * @param {LH.Config.Json=} configJson
- * @param {{isDebug?: boolean}=} testRunnerOptions
+ * @param {{isDebug?: boolean, useFraggleRock?: boolean}=} testRunnerOptions
  * @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
  */
 async function runLighthouse(url, configJson, testRunnerOptions = {}) {
   const tmpPath = await fs.mkdtemp(`${os.tmpdir()}/smokehouse-`);
 
   const {isDebug} = testRunnerOptions;
-  return internalRun(url, tmpPath, configJson, isDebug)
+  return internalRun(url, tmpPath, configJson, testRunnerOptions)
     // Wait for internalRun() before removing scratch directory.
     .finally(() => !isDebug && fs.rmdir(tmpPath, {recursive: true}));
 }
 
+/**
+ * @param {LH.Config.Json=} configJson
+ * @return {LH.Config.Json|undefined}
+ */
+function convertToFraggleRockConfig(configJson) {
+  if (!configJson) return configJson;
+  if (!configJson.passes) return configJson;
+
+  return {
+    ...configJson,
+    navigations: configJson.passes.map(pass => ({...pass, id: pass.passName})),
+  };
+}
+
 /**
  * Internal runner.
  * @param {string} url
  * @param {string} tmpPath
  * @param {LH.Config.Json=} configJson
- * @param {boolean=} isDebug
+ * @param {{isDebug?: boolean, useFraggleRock?: boolean}=} options
  * @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
  */
-async function internalRun(url, tmpPath, configJson, isDebug) {
+async function internalRun(url, tmpPath, configJson, options) {
+  const {isDebug = false, useFraggleRock = false} = options || {};
   const localConsole = new LocalConsole();
 
   const outputPath = `${tmpPath}/smokehouse.report.json`;
@@ -64,6 +79,11 @@ async function internalRun(url, tmpPath, configJson, isDebug) {
     '--port=0',
   ];
 
+  if (useFraggleRock) {
+    args.push('--fraggle-rock');
+    configJson = convertToFraggleRockConfig(configJson);
+  }
+
   // Config can be optionally provided.
   if (configJson) {
     const configPath = `${tmpPath}/config.json`;
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index dec9c9120..698508282 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -41,6 +41,7 @@ const DEFAULT_RETRIES = 0;
 async function runSmokehouse(smokeTestDefns, smokehouseOptions) {
   const {
     isDebug,
+    useFraggleRock,
     jobs = DEFAULT_CONCURRENT_RUNS,
     retries = DEFAULT_RETRIES,
     lighthouseRunner = cliLighthouseRunner,
@@ -52,7 +53,7 @@ async function runSmokehouse(smokeTestDefns, smokehouseOptions) {
   // Run each testDefn in parallel based on the concurrencyLimit.
   const concurrentMapper = new ConcurrentMapper();
 
-  const testOptions = {isDebug, retries, lighthouseRunner, takeNetworkRequestUrls};
+  const testOptions = {isDebug, useFraggleRock, retries, lighthouseRunner, takeNetworkRequestUrls};
   const smokePromises = smokeTestDefns.map(testDefn => {
     // If defn is set to `runSerially`, we'll run it in succession with other tests, not parallel.
     const concurrency = testDefn.runSerially ? 1 : jobs;
@@ -108,12 +109,12 @@ function purpleify(str) {
 /**
  * Run Lighthouse in the selected runner.
  * @param {Smokehouse.TestDfn} smokeTestDefn
- * @param {{isDebug?: boolean, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, takeNetworkRequestUrls?: () => string[]}} testOptions
+ * @param {{isDebug?: boolean, useFraggleRock?: boolean, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, takeNetworkRequestUrls?: () => string[]}} testOptions
  * @return {Promise<SmokehouseResult>}
  */
 async function runSmokeTest(smokeTestDefn, testOptions) {
   const {id, config: configJson, expectations} = smokeTestDefn;
-  const {lighthouseRunner, retries, isDebug, takeNetworkRequestUrls} = testOptions;
+  const {lighthouseRunner, retries, isDebug, useFraggleRock, takeNetworkRequestUrls} = testOptions;
   const requestedUrl = expectations.lhr.requestedUrl;
 
   console.log(`${purpleify(id)} smoketest starting…`);
@@ -131,7 +132,7 @@ async function runSmokeTest(smokeTestDefn, testOptions) {
     // Run Lighthouse.
     try {
       result = {
-        ...await lighthouseRunner(requestedUrl, configJson, {isDebug}),
+        ...await lighthouseRunner(requestedUrl, configJson, {isDebug, useFraggleRock}),
         networkRequests: takeNetworkRequestUrls ? takeNetworkRequestUrls() : undefined,
       };
     } catch (e) {
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index e02554528..e29629bd5 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -148,6 +148,15 @@ describe('Byte efficiency base audit', () => {
     });
   });
 
+  it('should not throw on invalid graph in timespan mode', () => {
+    assert.doesNotThrow(() => {
+      ByteEfficiencyAudit.createAuditProduct({
+        headings: baseHeadings,
+        items: [{wastedBytes: 350, totalBytes: 700, wastedPercent: 50}],
+      }, null, simulator, {gatherMode: 'timespan'});
+    });
+  });
+
   it('should populate KiB', () => {
     const result = ByteEfficiencyAudit.createAuditProduct({
       headings: baseHeadings,
@@ -329,4 +338,30 @@ describe('Byte efficiency base audit', () => {
     const result = await MockAudit.audit(artifacts, {settings, computedCache});
     expect(result.details.overallSavingsMs).toBeCloseTo(914.2695);
   });
+
+  it('should return n/a if no network records in timespan mode', async () => {
+    class MockAudit extends ByteEfficiencyAudit {
+      static audit_(artifacts, records) {
+        return {
+          items: records,
+          headings: [],
+        };
+      }
+    }
+
+    const artifacts = {
+      GatherContext: {gatherMode: 'timespan'},
+      traces: {defaultPass: trace},
+      devtoolsLogs: {defaultPass: []},
+    };
+    const computedCache = new Map();
+
+    const modestThrottling = {rttMs: 150, throughputKbps: 1000, cpuSlowdownMultiplier: 2};
+    const settings = {throttlingMethod: 'simulate', throttling: modestThrottling};
+    const result = await MockAudit.audit(artifacts, {settings, computedCache});
+    expect(result).toEqual({
+      notApplicable: true,
+      score: 1,
+    });
+  });
 });
diff --git a/lighthouse-core/test/audits/network-rtt-test.js b/lighthouse-core/test/audits/network-rtt-test.js
index a470c4229..bbdcda171 100644
--- a/lighthouse-core/test/audits/network-rtt-test.js
+++ b/lighthouse-core/test/audits/network-rtt-test.js
@@ -32,4 +32,14 @@ describe('Network RTT audit', () => {
       },
     ]);
   });
+
+  it('should return n/a if no network records', async () => {
+    const artifacts = {devtoolsLogs: {defaultPass: []}};
+    const result = await NetworkRTT.audit(artifacts, {computedCache: new Map()});
+
+    expect(result).toEqual({
+      notApplicable: true,
+      score: 1,
+    });
+  });
 });
diff --git a/lighthouse-core/test/audits/network-server-latency-test.js b/lighthouse-core/test/audits/network-server-latency-test.js
index 059be9f4d..621b53112 100644
--- a/lighthouse-core/test/audits/network-server-latency-test.js
+++ b/lighthouse-core/test/audits/network-server-latency-test.js
@@ -36,4 +36,14 @@ describe('Network Server Latency audit', () => {
       },
     ]);
   });
+
+  it('should return n/a if no network records', async () => {
+    const artifacts = {devtoolsLogs: {defaultPass: []}};
+    const result = await ServerLatency.audit(artifacts, {computedCache: new Map()});
+
+    expect(result).toEqual({
+      notApplicable: true,
+      score: 1,
+    });
+  });
 });
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index 55c68b311..5d7a4979e 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -174,11 +174,11 @@ describe('Fraggle Rock API', () => {
       const {auditResults, erroredAudits, notApplicableAudits} = getAuditsBreakdown(result.lhr);
       expect(auditResults.length).toMatchInlineSnapshot(`50`);
 
-      expect(notApplicableAudits.length).toMatchInlineSnapshot(`9`);
+      expect(notApplicableAudits.length).toMatchInlineSnapshot(`22`);
       expect(notApplicableAudits.map(audit => audit.id)).toContain('server-response-time');
 
       // TODO(FR-COMPAT): Reduce this number by handling the error, making N/A, or removing timespan support.
-      expect(erroredAudits.length).toMatchInlineSnapshot(`14`);
+      expect(erroredAudits.length).toMatchInlineSnapshot(`1`);
     });
   });
 
diff --git a/lighthouse-core/test/gather/gatherers/css-usage-test.js b/lighthouse-core/test/gather/gatherers/css-usage-test.js
index f3a7a54e9..2a5dd2c19 100644
--- a/lighthouse-core/test/gather/gatherers/css-usage-test.js
+++ b/lighthouse-core/test/gather/gatherers/css-usage-test.js
@@ -10,6 +10,9 @@
 const CSSUsage = require('../../../gather/gatherers/css-usage.js');
 const {defaultSettings} = require('../../../config/constants.js');
 const {createMockDriver} = require('../../fraggle-rock/gather/mock-driver.js');
+const {flushAllTimersAndMicrotasks} = require('../../test-utils.js');
+
+jest.useFakeTimers();
 
 describe('.getArtifact', () => {
   it('gets CSS usage', async () => {
@@ -19,7 +22,8 @@ describe('.getArtifact', () => {
       .mockEvent('CSS.styleSheetAdded', {header: {styleSheetId: '2'}});
     driver.defaultSession.sendCommand
       .mockResponse('DOM.enable')
-      .mockResponse('CSS.enable', undefined, 1) // Force events to emit
+      // @ts-expect-error - Force events to emit.
+      .mockResponse('CSS.enable', flushAllTimersAndMicrotasks)
       .mockResponse('CSS.startRuleUsageTracking')
       .mockResponse('CSS.getStyleSheetText', {text: 'CSS text 1'})
       .mockResponse('CSS.getStyleSheetText', {text: 'CSS text 2'})
@@ -68,6 +72,60 @@ describe('.getArtifact', () => {
     });
   });
 
+  it('ignores removed stylesheets', async () => {
+    const driver = createMockDriver();
+    driver.defaultSession.on
+      .mockEvent('CSS.styleSheetAdded', {header: {styleSheetId: '1'}})
+      .mockEvent('CSS.styleSheetAdded', {header: {styleSheetId: '2'}})
+      .mockEvent('CSS.styleSheetRemoved', {styleSheetId: '1'});
+    driver.defaultSession.sendCommand
+      .mockResponse('DOM.enable')
+      .mockResponse('CSS.enable')
+      .mockResponse('CSS.startRuleUsageTracking')
+      .mockResponse('CSS.getStyleSheetText', {text: 'CSS text 2'})
+      .mockResponse('CSS.stopRuleUsageTracking', {
+        ruleUsage: [
+          {styleSheetId: '2', used: false},
+        ],
+      })
+      .mockResponse('CSS.disable')
+      .mockResponse('DOM.disable');
+
+    /** @type {LH.Gatherer.FRTransitionalContext} */
+    const context = {
+      driver: driver.asDriver(),
+      url: 'https://example.com',
+      gatherMode: 'timespan',
+      computedCache: new Map(),
+      dependencies: {},
+      settings: defaultSettings,
+    };
+
+    const gatherer = new CSSUsage();
+    await gatherer.startInstrumentation(context);
+
+    // Force events to emit.
+    await flushAllTimersAndMicrotasks(1);
+
+    await gatherer.stopInstrumentation(context);
+    const artifact = await gatherer.getArtifact(context);
+
+    expect(artifact).toEqual({
+      stylesheets: [
+        {
+          header: {styleSheetId: '2'},
+          content: 'CSS text 2',
+        },
+      ],
+      rules: [
+        {
+          styleSheetId: '2',
+          used: false,
+        },
+      ],
+    });
+  });
+
   it('dedupes stylesheets', async () => {
     const driver = createMockDriver();
     driver.defaultSession.on
@@ -75,7 +133,8 @@ describe('.getArtifact', () => {
       .mockEvent('CSS.styleSheetAdded', {header: {styleSheetId: '1'}});
     driver.defaultSession.sendCommand
       .mockResponse('DOM.enable')
-      .mockResponse('CSS.enable', undefined, 1) // Force events to emit
+      // @ts-expect-error - Force events to emit.
+      .mockResponse('CSS.enable', flushAllTimersAndMicrotasks)
       .mockResponse('CSS.startRuleUsageTracking')
       .mockResponse('CSS.getStyleSheetText', {text: 'CSS text 1'})
       .mockResponse('CSS.getStyleSheetText', {text: 'CSS text 1'})
diff --git a/lighthouse-core/test/lib/page-functions-test.js b/lighthouse-core/test/lib/page-functions-test.js
index 37daff877..c90d47731 100644
--- a/lighthouse-core/test/lib/page-functions-test.js
+++ b/lighthouse-core/test/lib/page-functions-test.js
@@ -5,16 +5,9 @@
  */
 'use strict';
 
-// TODO(esmodules): Node 14, 16 crash with `--experimental-vm-modules` if require and import
-// are used in the same test file.
-// See https://github.com/GoogleChrome/lighthouse/pull/12702#issuecomment-876832620
-
-/** @type {import('assert').strict} */
-let assert;
-/** @type {import('jsdom').strict} */
-let jsdom;
-/** @type {import('../../lib/page-functions.js')} */
-let pageFunctions;
+const assert = require('assert');
+const jsdom = require('jsdom');
+const pageFunctions = require('../../lib/page-functions.js');
 
 /* eslint-env jest */
 
@@ -24,10 +17,6 @@ describe('Page Functions', () => {
   const url = 'http://www.example.com';
 
   beforeAll(async () => {
-    assert = (await import('assert')).strict;
-    jsdom = await import('jsdom');
-    pageFunctions = (await import('../../lib/page-functions.js')).default;
-
     const {document, ShadowRoot, Node, HTMLElement} = new jsdom.JSDOM('', {url}).window;
     global.ShadowRoot = ShadowRoot;
     global.Node = Node;
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index cba3a2af3..22cfda77f 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -28,7 +28,7 @@
     "gatherMode": "lighthouse-core/test/results/artifacts",
     "disableStorageReset": false,
     "formFactor": "mobile",
-    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse",
+    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Mobile Safari/537.36 Chrome-Lighthouse",
     "screenEmulation": {
       "deviceScaleFactor": 2.625,
       "height": 640,
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 8890acad5..7c45fafa6 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -4815,7 +4815,7 @@
       "deviceScaleFactor": 2.625,
       "disabled": false
     },
-    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse",
+    "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Mobile Safari/537.36 Chrome-Lighthouse",
     "auditMode": true,
     "gatherMode": false,
     "disableStorageReset": false,
@@ -8713,4 +8713,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}