Repository: GoogleChrome/lighthouse
Pull request #12827 (2021-07-26 20:49:56)

core(css-usage): ignore removed stylesheets
`CSS.getStyleSheetText` throws an error if the style sheet was removed. This causes issues in FR timespan mode because style sheets discovered during the timespan can be removed later in the timespan. Example:

```js
const puppeteer = require('puppeteer');
const fs = require('fs');
const open = require('open');
const defaultConfig = require('./lighthouse-core/fraggle-rock/config/default-config.js');
const lighthouse = require('./lighthouse-core/fraggle-rock/api.js');

async function run() {
  const browser = await puppeteer.launch({
    headless: false,
  })
  const page = await browser.newPage();
  const url = 'https://espn.com';

  const config = {
    ...defaultConfig,
    // audits: defaultConfig.audits.filter(a => a === 'byte-efficiency/unminified-css'),
    settings: {
      output: 'html'
    },
  };
  const run = await lighthouse.startTimespan({page, config});

  await page.goto(url);

  const {artifacts, report, lhr} = await run.endTimespan();
  fs.writeFileSync('lhr.json', JSON.stringify(lhr));
  fs.writeFileSync('artifacts.json', JSON.stringify(artifacts));
  fs.writeFileSync('fr-report.html', report);
  open('fr-report.html');

  await page.close();
  await browser.close();
}
run();

```

Simplest solution is to ignore any style sheet that was removed, which is implemented here. This does mean that styles are only tracked if they persist until the end of the timespan.

I experimented with a solution that ran `CSS.getStyleSheetText` as `CSS.styleSheetAdded` events rolled in rather than doing a batch run in `getArtifact`. Unfortunately, this didn't completely eliminate the risk of the style sheet being removed by the time `CSS.getStyleSheetText` is run.


patrickhulce: > What does this mean for the larger timespan experience, though? It seems like there's going to be multiple subtleties about the results you get from a few of our gatherers, and I don't feel like I have a good handle on the overall picture.

> It seems like this might be more of a fundamental issue with snapshots and timespans, though?

Are you referring to the fact that our traditional data isn't always available and the data we collect is very run-dependent? That *is* the fundamental issue with timespans and snapshots that all of @adamraine 's other work has been about slowly fixing 😄 We aren't getting there overnight, or even by the MVP. Immediate goal is an e2e working experience and permanent work thereafter to increase the scope of artifacts we can collect in every environment.

Patch:
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/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/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/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
+}