Repository: GoogleChrome/lighthouse (js)

Issue:
NO_TTI_CPU_IDLE_PERIOD if the page has a slow FCP but is idle immediately after it (devtools/provided throttling only)
#### Provide the steps to reproduce

I most consistently get the error in DevTools:

1. Got to https://persistent-friendly-authority.glitch.me/?delayFcpMs=1700
2. Open DevTools
3. In Lighthouse settings untick "Simulated throttling"
4. Click "generate report"

I get it with the CLI as well, but only about 1 in 4 times or so. Since the issue seems to happen for certain timings you might have to play with the `delayFcpMs` query param. (I also used a locally hosted server with an FCP delay of 2000ms.)

1. Go to https://persistent-friendly-authority.glitch.me/?delayFcpMs=1700 to wake up the Glitch page
2. Run `node lighthouse-cli/index.js  --view --throttlingMethod provided  https://persistent-friendly-authority.glitch.me/\?delayFcpMs\=1700`

#### What is the current behavior?

Lighthouse reports no performance score and shows a NO_TTI_CPU_IDLE_PERIOD error.

![Screenshot 2020-03-25 at 10 11 42](https://user-images.githubusercontent.com/1303660/77525514-0f9ceb00-6e81-11ea-8832-23b9c292bf0a.png)

If a 400ms loop is added after setting the body HTML the error no longer occurs.

#### What is the expected behavior?

There shouldn't be an error.

#### Environment Information
* Affected Channels: All
* Lighthouse version: 5.7.1 (Canary DevTools), master 1457b4ce
* Chrome version: 83.0.4094.0 + others
* Node.js version: v10.15.3
* Operating System: macOS Catalina 10.15.3

Pull request description:
core(driver): pause after FCP event before resolving load
<!-- Thank you for submitting a pull request! -->

**Summary**
TTI needs at least 5s of quiet after FCP in order to be computed for observed metrics. Currently we wait for 5s after load, network quiet, and CPU quiet and in a vast majority of cases at least one of these occurs at or after FCP so it doesn't matter, but in some cases it does. This PR updates our logic to wait 5s after FCP as well before resolving the page as "loaded"

**Related Issues/PRs**
fixes #10503


Patch:
diff --git a/docs/variability.md b/docs/variability.md
index 72f9a721b..5d2d16966 100644
--- a/docs/variability.md
+++ b/docs/variability.md
@@ -64,6 +64,20 @@ Below is a table containing several common sources of metric variability, the ty
 
 ## Strategies for Dealing With Variance
 
+### Run on Adequate Hardware
+
+Loading modern webpages on a modern browser is not an easy task. Using appropriately powerful hardware can make a world of difference when it comes to variability.
+
+- Minimum 2 dedicated cores (4 recommended)
+- Minimum 2GB RAM (4-8GB recommended)
+- Avoid non-standard Chromium flags (`--single-process` is not supported, `--no-sandbox` and `--headless` should be OK, though educate yourself about [sandbox tradeoffs](https://github.com/GoogleChrome/lighthouse-ci/tree/fbb540507c031100ee13bf7eb1a4b61c79c5e1e6/docs/recipes/docker-client#--no-sandbox-issues-explained))
+- Avoid function-as-a-service infrastructure (Lambda, GCF, etc)
+- Avoid "burstable" or "shared-core" instance types (AWS `t` instances, GCP shared-core N1 and E2 instances, etc)
+
+AWS's `m5.large`, GCP's `n2-standard-2`, and Azure's `D2` all should be sufficient to run a single Lighthouse run at a time (~$0.10/hour for these instance types, ~30s/test, ~$0.0008/Lighthouse report). While some environments that don't meet the requirements above will still be able to run Lighthouse and the non-performance results will still be usable, we'd advise against it and won't be able to support those environments should any bugs arise. Remember, running on inconsistent hardware will lead to inconsistent results!
+
+**DO NOT** collect multiple Lighthouse reports at the same time on the same machine. Concurrent runs can skew performance results due to resource contention. When it comes to Lighthouse runs, scaling horizontally is better than scaling vertically (i.e. run with 4 `n2-standard-2` instead of 1 `n2-standard-8`).
+
 ### Isolate External Factors
 
 - Isolate your page from third-party influence as much as possible. It’s never fun to be blamed for someone else's variable failures.
@@ -77,10 +91,10 @@ If your machine has really limited resources or creating a clean environment has
 
 When creating your thresholds for failure, either mental or programmatic, use aggregate values like the median, 90th percentile, or even min instead of single tests.
 
-The median Lighthouse score of 5 runs is twice as stable as 1 run, and tools like [pwmetrics](https://github.com/paulirish/pwmetrics) can run Lighthouse for you automatically. Using the minimum value is also a big improvement over not testing at all and is incredibly simple to implement, just run Lighthouse up to 5 times until it passes!
+The median Lighthouse score of 5 runs is twice as stable as 1 run, and tools like [lighthouse-ci](https://github.com/GoogleChrome/lighthouse-ci/) can run Lighthouse multiple times for you automatically. Using the minimum value is also a big improvement over not testing at all and is incredibly simple to implement, just run Lighthouse up to 5 times until it passes!
 
 ## Related Documentation
 
 - [Lighthouse Variability and Accuracy Analysis](https://docs.google.com/document/d/1BqtL-nG53rxWOI5RO0pItSRPowZVnYJ_gBEQCJ5EeUE/edit?usp=sharing)
 - [Throttling documentation](./throttling.md)
-- [Why is my Lighthouse score different from PageSpeed Insights?](https://www.debugbear.com/blog/why-is-my-lighthouse-score-different-from-pagespeed-insights)
\ No newline at end of file
+- [Why is my Lighthouse score different from PageSpeed Insights?](https://www.debugbear.com/blog/why-is-my-lighthouse-score-different-from-pagespeed-insights)
diff --git a/lighthouse-core/audits/byte-efficiency/unused-javascript.js b/lighthouse-core/audits/byte-efficiency/unused-javascript.js
index 6ac918c29..e18b1d7b4 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-javascript.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-javascript.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Remove unused JavaScript',
   /** Description of a Lighthouse audit that tells the user *why* they should remove JavaScript that is never needed/evaluated by the browser. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Remove unused JavaScript to reduce bytes consumed by network activity. ' +
-    '[Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting).',
+    '[Learn more](https://web.dev/remove-unused-code/).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 41c4f95b9..5c1a416f0 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -546,6 +546,8 @@ class Config {
     const defaultPass = passes.find(pass => pass.passName === 'defaultPass');
     if (!defaultPass) return;
     const overrides = constants.nonSimulatedPassConfigOverrides;
+    defaultPass.pauseAfterFcpMs =
+      Math.max(overrides.pauseAfterFcpMs, defaultPass.pauseAfterFcpMs);
     defaultPass.pauseAfterLoadMs =
       Math.max(overrides.pauseAfterLoadMs, defaultPass.pauseAfterLoadMs);
     defaultPass.cpuQuietThresholdMs =
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index 3fbf39c47..634c7a877 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -83,6 +83,7 @@ const defaultPassConfig = {
   loadFailureMode: 'fatal',
   recordTrace: false,
   useThrottling: false,
+  pauseAfterFcpMs: 0,
   pauseAfterLoadMs: 0,
   networkQuietThresholdMs: 0,
   cpuQuietThresholdMs: 0,
@@ -92,6 +93,7 @@ const defaultPassConfig = {
 };
 
 const nonSimulatedPassConfigOverrides = {
+  pauseAfterFcpMs: 5250,
   pauseAfterLoadMs: 5250,
   networkQuietThresholdMs: 5250,
   cpuQuietThresholdMs: 5250,
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 605ccf5ca..03bb28ef5 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -122,6 +122,7 @@ const defaultConfig = {
     passName: 'defaultPass',
     recordTrace: true,
     useThrottling: true,
+    pauseAfterFcpMs: 1000,
     pauseAfterLoadMs: 1000,
     networkQuietThresholdMs: 1000,
     cpuQuietThresholdMs: 1000,
diff --git a/lighthouse-core/config/no-throttling-config.js b/lighthouse-core/config/no-throttling-config.js
new file mode 100644
index 000000000..82fc36476
--- /dev/null
+++ b/lighthouse-core/config/no-throttling-config.js
@@ -0,0 +1,17 @@
+/**
+ * @license Copyright 2020 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';
+
+/** @type {LH.Config.Json} */
+const perfConfig = {
+  extends: 'lighthouse:default',
+  settings: {
+    throttlingMethod: 'provided',
+    onlyCategories: ['performance'],
+  },
+};
+
+module.exports = perfConfig;
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 4b82f7249..d040ad800 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -24,6 +24,8 @@ const pageFunctions = require('../lib/page-functions.js');
 // eslint-disable-next-line no-unused-vars
 const Connection = require('./connections/connection.js');
 
+// Controls how long to wait after FCP before continuing
+const DEFAULT_PAUSE_AFTER_FCP = 0;
 // Controls how long to wait after onLoad before continuing
 const DEFAULT_PAUSE_AFTER_LOAD = 0;
 // Controls how long to wait between network requests before determining the network is quiet
@@ -649,10 +651,11 @@ class Driver {
 
   /**
    * Returns a promise that resolve when a frame has a FCP.
+   * @param {number} pauseAfterFcpMs
    * @param {number} maxWaitForFCPMs
    * @return {{promise: Promise<void>, cancel: function(): void}}
    */
-  _waitForFCP(maxWaitForFCPMs) {
+  _waitForFCP(pauseAfterFcpMs, maxWaitForFCPMs) {
     /** @type {(() => void)} */
     let cancel = () => {
       throw new Error('_waitForFCP.cancel() called before it was defined');
@@ -662,12 +665,16 @@ class Driver {
       const maxWaitTimeout = setTimeout(() => {
         reject(new LHError(LHError.errors.NO_FCP));
       }, maxWaitForFCPMs);
+      /** @type {NodeJS.Timeout|undefined} */
+      let loadTimeout;
 
       /** @param {LH.Crdp.Page.LifecycleEventEvent} e */
       const lifecycleListener = e => {
         if (e.name === 'firstContentfulPaint') {
-          resolve();
-          cancel();
+          loadTimeout = setTimeout(() => {
+            resolve();
+            cancel();
+          }, pauseAfterFcpMs);
         }
       };
 
@@ -679,6 +686,7 @@ class Driver {
         canceled = true;
         this.off('Page.lifecycleEvent', lifecycleListener);
         maxWaitTimeout && clearTimeout(maxWaitTimeout);
+        loadTimeout && clearTimeout(loadTimeout);
         reject(new Error('Wait for FCP canceled'));
       };
     });
@@ -904,21 +912,24 @@ class Driver {
    *    - cpuQuietThresholdMs have passed since the last long task after network-2-quiet.
    * - maxWaitForLoadedMs milliseconds have passed.
    * See https://github.com/GoogleChrome/lighthouse/issues/627 for more.
+   * @param {number} pauseAfterFcpMs
    * @param {number} pauseAfterLoadMs
    * @param {number} networkQuietThresholdMs
    * @param {number} cpuQuietThresholdMs
    * @param {number} maxWaitForLoadedMs
-   * @param {number=} maxWaitForFCPMs
+   * @param {number=} maxWaitForFcpMs
    * @return {Promise<void>}
    * @private
    */
-  async _waitForFullyLoaded(pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs,
-      maxWaitForLoadedMs, maxWaitForFCPMs) {
+  async _waitForFullyLoaded(pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs,
+      cpuQuietThresholdMs, maxWaitForLoadedMs, maxWaitForFcpMs) {
     /** @type {NodeJS.Timer|undefined} */
     let maxTimeoutHandle;
 
-    // Listener for onload. Resolves on first FCP event.
-    const waitForFCP = maxWaitForFCPMs ? this._waitForFCP(maxWaitForFCPMs) : this._waitForNothing();
+    // Listener for FCP. Resolves pauseAfterFcpMs ms after first FCP event.
+    const waitForFCP = maxWaitForFcpMs ?
+      this._waitForFCP(pauseAfterFcpMs, maxWaitForFcpMs) :
+      this._waitForNothing();
     // Listener for onload. Resolves pauseAfterLoadMs ms after load.
     const waitForLoadEvent = this._waitForLoadEvent(pauseAfterLoadMs);
     // Network listener. Resolves when the network has been idle for networkQuietThresholdMs.
@@ -926,7 +937,7 @@ class Driver {
     // CPU listener. Resolves when the CPU has been idle for cpuQuietThresholdMs after network idle.
     let waitForCPUIdle = this._waitForNothing();
 
-    // Wait for both load promises. Resolves on cleanup function the clears load
+    // Wait for all initial load promises. Resolves on cleanup function the clears load
     // timeout timer.
     const loadPromise = Promise.all([
       waitForFCP.promise,
@@ -1094,11 +1105,13 @@ class Driver {
       await this._waitForFrameNavigated();
     } else if (waitForLoad) {
       const passConfig = /** @type {Partial<LH.Config.Pass>} */ (passContext.passConfig || {});
-      let {pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = passConfig;
+
+      /* eslint-disable max-len */
+      let {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = passConfig;
       let maxWaitMs = passContext.settings && passContext.settings.maxWaitForLoad;
       let maxFCPMs = passContext.settings && passContext.settings.maxWaitForFcp;
 
-      /* eslint-disable max-len */
+      if (typeof pauseAfterFcpMs !== 'number') pauseAfterFcpMs = DEFAULT_PAUSE_AFTER_FCP;
       if (typeof pauseAfterLoadMs !== 'number') pauseAfterLoadMs = DEFAULT_PAUSE_AFTER_LOAD;
       if (typeof networkQuietThresholdMs !== 'number') networkQuietThresholdMs = DEFAULT_NETWORK_QUIET_THRESHOLD;
       if (typeof cpuQuietThresholdMs !== 'number') cpuQuietThresholdMs = DEFAULT_CPU_QUIET_THRESHOLD;
@@ -1107,8 +1120,8 @@ class Driver {
       /* eslint-enable max-len */
 
       if (!waitForFCP) maxFCPMs = undefined;
-      await this._waitForFullyLoaded(pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs,
-          maxWaitMs, maxFCPMs);
+      await this._waitForFullyLoaded(pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs,
+        cpuQuietThresholdMs, maxWaitMs, maxFCPMs);
     }
 
     // Bring `Page.navigate` errors back into the promise chain. See https://github.com/GoogleChrome/lighthouse/pull/6739.
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 285d84509..4494d4e9c 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -468,7 +468,7 @@
     "message": "Remove unused CSS"
   },
   "lighthouse-core/audits/byte-efficiency/unused-javascript.js | description": {
-    "message": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting)."
+    "message": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://web.dev/remove-unused-code/)."
   },
   "lighthouse-core/audits/byte-efficiency/unused-javascript.js | title": {
     "message": "Remove unused JavaScript"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index df8d2e728..de948ecf6 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -468,7 +468,7 @@
     "message": "R̂ém̂óv̂é ûńûśêd́ ĈŚŜ"
   },
   "lighthouse-core/audits/byte-efficiency/unused-javascript.js | description": {
-    "message": "R̂ém̂óv̂é ûńûśêd́ Ĵáv̂áŜćr̂íp̂t́ t̂ó r̂éd̂úĉé b̂ýt̂éŝ ćôńŝúm̂éd̂ b́ŷ ńêt́ŵór̂ḱ âćt̂ív̂ít̂ý. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting)."
+    "message": "R̂ém̂óv̂é ûńûśêd́ Ĵáv̂áŜćr̂íp̂t́ t̂ó r̂éd̂úĉé b̂ýt̂éŝ ćôńŝúm̂éd̂ b́ŷ ńêt́ŵór̂ḱ âćt̂ív̂ít̂ý. [L̂éâŕn̂ ḿôŕê](https://web.dev/remove-unused-code/)."
   },
   "lighthouse-core/audits/byte-efficiency/unused-javascript.js | title": {
     "message": "R̂ém̂óv̂é ûńûśêd́ Ĵáv̂áŜćr̂íp̂t́"
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index a64b4fa3f..d37b7d502 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3039,7 +3039,7 @@
             "title": "Remove unused CSS"
         },
         "unused-javascript": {
-            "description": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting).",
+            "description": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://web.dev/remove-unused-code/).",
             "details": {
                 "headings": [],
                 "items": [],
diff --git a/types/config.d.ts b/types/config.d.ts
index a98dd649d..b3e9c7842 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -28,6 +28,7 @@ declare global {
         loadFailureMode?: 'fatal'|'warn'|'ignore';
         recordTrace?: boolean;
         useThrottling?: boolean;
+        pauseAfterFcpMs?: number;
         pauseAfterLoadMs?: number;
         networkQuietThresholdMs?: number;
         cpuQuietThresholdMs?: number;


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 96d9629e2..37b6a8155 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1294,6 +1294,7 @@ Object {
       "loadFailureMode": "fatal",
       "networkQuietThresholdMs": 1000,
       "passName": "defaultPass",
+      "pauseAfterFcpMs": 1000,
       "pauseAfterLoadMs": 1000,
       "recordTrace": true,
       "useThrottling": true,
@@ -1316,6 +1317,7 @@ Object {
       "loadFailureMode": "ignore",
       "networkQuietThresholdMs": 0,
       "passName": "offlinePass",
+      "pauseAfterFcpMs": 0,
       "pauseAfterLoadMs": 0,
       "recordTrace": false,
       "useThrottling": false,
@@ -1345,6 +1347,7 @@ Object {
       "loadFailureMode": "warn",
       "networkQuietThresholdMs": 0,
       "passName": "redirectPass",
+      "pauseAfterFcpMs": 0,
       "pauseAfterLoadMs": 0,
       "recordTrace": false,
       "useThrottling": false,
@@ -1481,6 +1484,7 @@ Object {
       "loadFailureMode": "fatal",
       "networkQuietThresholdMs": 1000,
       "passName": "defaultPass",
+      "pauseAfterFcpMs": 1000,
       "pauseAfterLoadMs": 1000,
       "recordTrace": true,
       "useThrottling": true,
diff --git a/lighthouse-cli/test/fixtures/tricky-tti-late-fcp.html b/lighthouse-cli/test/fixtures/tricky-tti-late-fcp.html
new file mode 100644
index 000000000..fae412caa
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/tricky-tti-late-fcp.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<!--
+ * Copyright 2020 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.
+-->
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+<body>
+  <script>
+    /**
+     * Stalls the main thread for timeInMs
+     */
+    function stall(timeInMs) {
+      const start = performance.now();
+      while (performance.now() - start < timeInMs) {
+        for (let i = 0; i < 1000000; i++) ;
+      }
+    }
+
+    setTimeout(() => stall(100), 1000); // stall for 100ms, 1 second out
+    setTimeout(() => stall(100), 3000); // stall for 100ms, 3 seconds out
+
+    // FCP at 5 seconds out, after load, network quiet, and CPU quiet
+    // NOTE: devtools throttling has a bug that makes even this layout become a long task
+    // so must be run with --throttling-method=provided.
+    setTimeout(() => document.body.textContent = 'Hello FCP!', 5000);
+  </script>
+</body>
+</html>
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
index ce845b3e1..3985ac647 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
@@ -65,7 +65,7 @@ const smokeTests = [{
 }, {
   id: 'metrics',
   expectations: require('./tricky-metrics/expectations.js'),
-  config: require('../../../../lighthouse-core/config/perf-config.js'),
+  config: require('../../../../lighthouse-core/config/no-throttling-config.js'),
 }, {
   id: 'mixed-content',
   expectations: require('./mixed-content/mixed-content-expectations.js'),
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/tricky-metrics/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/tricky-metrics/expectations.js
index 385bf5da3..1716ba01f 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/tricky-metrics/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/tricky-metrics/expectations.js
@@ -26,6 +26,22 @@ module.exports = [
       },
     },
   },
+  {
+    lhr: {
+      requestedUrl: 'http://localhost:10200/tricky-tti-late-fcp.html',
+      finalUrl: 'http://localhost:10200/tricky-tti-late-fcp.html',
+      audits: {
+        'first-cpu-idle': {
+          // FCP at ~5 seconds out
+          numericValue: '>4900',
+        },
+        'interactive': {
+          // FCP at ~5 seconds out
+          numericValue: '>4900',
+        },
+      },
+    },
+  },
   {
     lhr: {
       requestedUrl: 'http://localhost:10200/delayed-fcp.html',
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index 2bb74098c..8bbd1c330 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -833,6 +833,7 @@ describe('Config', () => {
 
     assert.equal(config.settings.throttlingMethod, 'devtools');
     assert.equal(config.passes[0].passName, 'defaultPass');
+    assert.ok(config.passes[0].pauseAfterFcpMs >= 5000, 'did not adjust fcp quiet ms');
     assert.ok(config.passes[0].pauseAfterLoadMs >= 5000, 'did not adjust load quiet ms');
     assert.ok(config.passes[0].cpuQuietThresholdMs >= 5000, 'did not adjust cpu quiet ms');
     assert.ok(config.passes[0].networkQuietThresholdMs >= 5000, 'did not adjust network quiet ms');
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index c1764bc5c..f40cf664a 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -659,7 +659,7 @@ describe('._waitForFCP', () => {
   it('should not resolve until FCP fires', async () => {
     driver.on = driver.once = createMockOnceFn();
 
-    const waitPromise = makePromiseInspectable(driver._waitForFCP(60 * 1000).promise);
+    const waitPromise = makePromiseInspectable(driver._waitForFCP(0, 60 * 1000).promise);
     const listener = driver.on.findListener('Page.lifecycleEvent');
 
     await flushAllTimersAndMicrotasks();
@@ -675,10 +675,30 @@ describe('._waitForFCP', () => {
     await waitPromise;
   });
 
+  it('should wait for pauseAfterFcpMs after FCP', async () => {
+    driver.on = driver.once = createMockOnceFn();
+
+    const waitPromise = makePromiseInspectable(driver._waitForFCP(5000, 60 * 1000).promise);
+    const listener = driver.on.findListener('Page.lifecycleEvent');
+
+    await flushAllTimersAndMicrotasks();
+    expect(waitPromise).not.toBeDone('Resolved without FCP');
+
+    listener({name: 'firstContentfulPaint'});
+    await flushAllTimersAndMicrotasks();
+    expect(waitPromise).not.toBeDone('Did not wait for pauseAfterFcpMs');
+
+    jest.advanceTimersByTime(5001);
+    await flushAllTimersAndMicrotasks();
+    expect(waitPromise).toBeDone('Did not resolve after pauseAfterFcpMs');
+
+    await waitPromise;
+  });
+
   it('should timeout', async () => {
     driver.on = driver.once = createMockOnceFn();
 
-    const waitPromise = makePromiseInspectable(driver._waitForFCP(5000).promise);
+    const waitPromise = makePromiseInspectable(driver._waitForFCP(0, 5000).promise);
 
     await flushAllTimersAndMicrotasks();
     expect(waitPromise).not.toBeDone('Resolved before timeout');
@@ -693,7 +713,7 @@ describe('._waitForFCP', () => {
     driver.on = driver.once = createMockOnceFn();
     driver.off = jest.fn();
 
-    const {promise: rawPromise, cancel} = driver._waitForFCP(5000);
+    const {promise: rawPromise, cancel} = driver._waitForFCP(0, 5000);
     const waitPromise = makePromiseInspectable(rawPromise);
 
     await flushAllTimersAndMicrotasks();
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index e202a0449..e06bca183 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2737,7 +2737,7 @@
     "unused-javascript": {
       "id": "unused-javascript",
       "title": "Remove unused JavaScript",
-      "description": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/code-splitting).",
+      "description": "Remove unused JavaScript to reduce bytes consumed by network activity. [Learn more](https://web.dev/remove-unused-code/).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,