Repository: GoogleChrome/lighthouse (js)

Issue:
Offline pass doesn't always go offline
#### Provide the steps to reproduce
1. Run LH on https://www.bhphotovideo.com/
 

#### What is the current behavior?

Lighthouse reports that the start URL does not load offline but the current page does.

You can observe that in the offline pass, the desktop version of the page is loaded, and upon inspecting lighthouse internals see that the `fromServiceWorker` and `fromDiskCache` flags on all network requests are `false`. This shouldn't be possible when offline.

#### What is the expected behavior?

Lighthouse reports that the start URL loads offline. A page like the below should be visible during the offline pass, not the desktop version of the site.

![image](https://user-images.githubusercontent.com/2301202/61826719-a1eb7b80-ae28-11e9-88c7-6145188e8763.png)


#### Environment Information
* Affected Channels: CLI
* Lighthouse version: 5.2.0
* Node.js version: v10.15.3
* Operating System: macOS

**Related issues**
brought over from https://github.com/GoogleChrome/lighthouse/issues/2688#issuecomment-514738794

Pull request:
core(start-url): stay offline for entirety of offlinePass
**Summary**
tl;dr - `StartUrl` gatherer is not actually offline while it is checking for service worker start URL fetching. This PR fixes that.

In #6957, the start url gatherer was refactored to allow WebAppManifest to be part of core artifacts and avoid being double fetched. The code appeared to assume that the page was offline at the start of the `afterPass` because [it went back online](https://github.com/GoogleChrome/lighthouse/commit/fb6c376feb8ee7161137baf5f86e978b4f9d7bfc#diff-dd5e1188fbf8a1aa7763695960187be1L21) to fetch the manifest before going back offline. This wasn't actually the case though. Due to refactorings long ago in gather runner we actually always run the entirety of `afterPass` while online.

For pages with service workers that are always controlling the page this doesn't end up making much of a difference because of good ol' #709. However, if the service worker only handles requests while the page is in an offline state (not really clear *how* one does this), then all requests actually go out over the network and we trip our `!response.fromServiceWorker` line to falsely fail the page (which is normally a good thing because it means that all pages that didn't have a service worker didn't magically start passing when this bug was introduced! 😄)

**Related Issues/PRs**
fixes #9450


Patch:
diff --git a/lighthouse-core/gather/gatherers/offline.js b/lighthouse-core/gather/gatherers/offline.js
index 673f0d455..5b9814bc0 100644
--- a/lighthouse-core/gather/gatherers/offline.js
+++ b/lighthouse-core/gather/gatherers/offline.js
@@ -13,6 +13,9 @@ class Offline extends Gatherer {
    * @param {LH.Gatherer.PassContext} passContext
    */
   beforePass(passContext) {
+    // This call sets up the offline state for the page navigation of the `offlinePass` in gather-runner.
+    // gather-runner will automatically go back online before the `afterPass` phase, so no additional
+    // cleanup is necessary.
     return passContext.driver.goOffline();
   }
 
@@ -21,14 +24,13 @@ class Offline extends Gatherer {
    * @param {LH.Gatherer.LoadData} loadData
    * @return {Promise<LH.Artifacts['Offline']>}
    */
-  afterPass(passContext, loadData) {
+  async afterPass(passContext, loadData) {
     const navigationRecord = loadData.networkRecords.filter(record => {
       return URL.equalWithExcludedFragments(record.url, passContext.url) &&
         record.fetchedViaServiceWorker;
     }).pop(); // Take the last record that matches.
 
-    return passContext.driver.goOnline(passContext)
-      .then(_ => navigationRecord ? navigationRecord.statusCode : -1);
+    return navigationRecord ? navigationRecord.statusCode : -1;
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/start-url.js b/lighthouse-core/gather/gatherers/start-url.js
index e19e1effe..07c387fe7 100644
--- a/lighthouse-core/gather/gatherers/start-url.js
+++ b/lighthouse-core/gather/gatherers/start-url.js
@@ -11,20 +11,36 @@ const Gatherer = require('./gatherer.js');
 
 class StartUrl extends Gatherer {
   /**
-   * Grab the manifest, extract it's start_url, attempt to `fetch()` it while offline
+   * Go offline, assess the start url, go back online.
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['StartUrl']>}
    */
   async afterPass(passContext) {
+    // `afterPass` is always online, so manually go offline to check start_url.
+    await passContext.driver.goOffline();
+    const result = await this._determineStartUrlAvailability(passContext);
+    await passContext.driver.goOnline(passContext);
+
+    return result;
+  }
+
+  /**
+   * Grab the manifest, extract its start_url, attempt to `fetch()` it while offline
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['StartUrl']>}
+   */
+  async _determineStartUrlAvailability(passContext) {
     const manifest = passContext.baseArtifacts.WebAppManifest;
     const startUrlInfo = this._readManifestStartUrl(manifest);
     if (startUrlInfo.isReadFailure) {
       return {statusCode: -1, explanation: startUrlInfo.reason};
     }
 
-    return this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl).catch(() => {
-      return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker.'};
-    });
+    try {
+      return await this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl);
+    } catch (err) {
+      return {statusCode: -1, explanation: 'Error while fetching start_url via service worker.'};
+    }
   }
 
   /**
@@ -59,7 +75,7 @@ class StartUrl extends Gatherer {
     // Wait up to 3s to get a matched network request from the fetch() to work
     const timeoutPromise = new Promise(resolve =>
       setTimeout(
-        () => resolve({statusCode: -1, explanation: 'Timed out waiting for fetched start_url.'}),
+        () => resolve({statusCode: -1, explanation: 'Timed out waiting for start_url to respond.'}),
         3000
       )
     );
@@ -77,7 +93,7 @@ class StartUrl extends Gatherer {
         if (!response.fromServiceWorker) {
           return resolve({
             statusCode: -1,
-            explanation: 'Unable to fetch start URL via service worker.',
+            explanation: 'The start_url did respond, but not via a service worker.',
           });
         }
         // Successful SW-served fetch of the start_URL
diff --git a/lighthouse-core/report/html/renderer/dom.js b/lighthouse-core/report/html/renderer/dom.js
index 28de47bb2..4f7b0a94d 100644
--- a/lighthouse-core/report/html/renderer/dom.js
+++ b/lighthouse-core/report/html/renderer/dom.js
@@ -127,8 +127,8 @@ class DOM {
       // Otherwise, append any links found.
       const url = new URL(segment.linkHref);
 
-      const DEVELOPERS_GOOGLE_ORIGIN = 'https://developers.google.com';
-      if (url.origin === DEVELOPERS_GOOGLE_ORIGIN) {
+      const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev'];
+      if (DOCS_ORIGINS.includes(url.origin)) {
         url.searchParams.set('utm_source', 'lighthouse');
         url.searchParams.set('utm_medium', this._lighthouseChannel);
       }
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 41be88539..94a07a617 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -51,6 +51,8 @@ class ReportUIFeatures {
     /** @type {HTMLElement} */
     this.toolsButton; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
+    this.toolsDropDown; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
     this.topbarEl; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
     this.scoreScaleEl; // eslint-disable-line no-unused-expressions
@@ -62,6 +64,8 @@ class ReportUIFeatures {
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
     this.onToolsButtonClick = this.onToolsButtonClick.bind(this);
+    this.onToolsButtonKeydown = this.onToolsButtonKeydown.bind(this);
+    this.onToolsDropDownKeydown = this.onToolsDropDownKeydown.bind(this);
     this.onToolAction = this.onToolAction.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onKeyUp = this.onKeyUp.bind(this);
@@ -70,6 +74,9 @@ class ReportUIFeatures {
     this.expandAllDetails = this.expandAllDetails.bind(this);
     this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
     this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
+    this._getNextDropDownItem = this._getNextDropDownItem.bind(this);
+    this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
+    this._getPreviousDropDownItem = this._getPreviousDropDownItem.bind(this);
   }
 
   /**
@@ -208,9 +215,11 @@ class ReportUIFeatures {
   _setupToolsButton() {
     this.toolsButton = this._dom.find('.lh-tools__button', this._document);
     this.toolsButton.addEventListener('click', this.onToolsButtonClick);
+    this.toolsButton.addEventListener('keydown', this.onToolsButtonKeydown);
 
-    const dropdown = this._dom.find('.lh-tools__dropdown', this._document);
-    dropdown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown = this._dom.find('.lh-tools__dropdown', this._document);
+    this.toolsDropDown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown.addEventListener('keydown', this.onToolsDropDownKeydown);
   }
 
   _setupThirdPartyFilter() {
@@ -383,6 +392,31 @@ class ReportUIFeatures {
 
   closeToolsDropdown() {
     this.toolsButton.classList.remove('active');
+    this.toolsButton.setAttribute('aria-expanded', 'false');
+    this._document.removeEventListener('keydown', this.onKeyDown);
+    if (this.toolsDropDown.contains(this._document.activeElement)) {
+      // Refocus on the tools button if the drop down last had focus
+      this.toolsButton.focus();
+    }
+  }
+
+  /**
+   * @param {HTMLElement} firstFocusElement
+   */
+  openToolsDropDown(firstFocusElement) {
+    if (this.toolsButton.classList.contains('active')) {
+      // If the drop down is already open focus on the element
+      firstFocusElement.focus();
+    } else {
+      // Wait for drop down transition to complete so options are focusable.
+      this.toolsDropDown.addEventListener('transitionend', () => {
+        firstFocusElement.focus();
+      }, {once: true});
+    }
+
+    this.toolsButton.classList.add('active');
+    this.toolsButton.setAttribute('aria-expanded', 'true');
+    this._document.addEventListener('keydown', this.onKeyDown);
   }
 
   /**
@@ -391,8 +425,113 @@ class ReportUIFeatures {
    */
   onToolsButtonClick(e) {
     e.preventDefault();
-    this.toolsButton.classList.toggle('active');
-    this._document.addEventListener('keydown', this.onKeyDown);
+    e.stopImmediatePropagation();
+
+    if (this.toolsButton.classList.contains('active')) {
+      this.closeToolsDropdown();
+    } else {
+      this.openToolsDropDown(this._getNextDropDownItem());
+    }
+  }
+
+  /**
+   * Handler for tool button.
+   * @param {KeyboardEvent} e
+   */
+  onToolsButtonKeydown(e) {
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this.openToolsDropDown(this._getPreviousDropDownItem());
+        break;
+      case 'ArrowDown':
+      case 'Enter':
+      case ' ':
+        e.preventDefault();
+        this.openToolsDropDown(this._getNextDropDownItem());
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Handler for tool DropDown.
+   * @param {KeyboardEvent} e
+   */
+  onToolsDropDownKeydown(e) {
+    const el = /** @type {?HTMLElement} */ (e.target);
+
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this._getPreviousDropDownItem(el).focus();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._getNextDropDownItem(el).focus();
+        break;
+      case 'Home':
+        e.preventDefault();
+        this._getNextDropDownItem().focus();
+        break;
+      case 'End':
+        e.preventDefault();
+        this._getPreviousDropDownItem().focus();
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * @param {Array<Node>} allNodes
+   * @param {?Node=} startNode
+   * @returns {Node}
+   */
+  _getNextSelectableNode(allNodes, startNode) {
+    const nodes = allNodes.filter((node) => {
+      if (!(node instanceof HTMLElement)) {
+        return false;
+      }
+
+      // 'Save as Gist' option may be disabled.
+      if (node.hasAttribute('disabled')) {
+        return false;
+      }
+
+      // 'Save as Gist' option may have display none.
+      if (window.getComputedStyle(node).display === 'none') {
+        return false;
+      }
+
+      return true;
+    });
+
+    let nextIndex = startNode ? (nodes.indexOf(startNode) + 1) : 0;
+    if (nextIndex >= nodes.length) {
+      nextIndex = 0;
+    }
+
+    return nodes[nextIndex];
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getNextDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes);
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getPreviousDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes).reverse();
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
   }
 
   /**
@@ -424,12 +563,10 @@ class ReportUIFeatures {
         break;
       case 'print-summary':
         this.collapseAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'print-expanded':
         this.expandAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'save-json': {
@@ -464,7 +601,6 @@ class ReportUIFeatures {
     }
 
     this.closeToolsDropdown();
-    this._document.removeEventListener('keydown', this.onKeyDown);
   }
 
   _print() {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index fd08b45bd..3cd8cdefc 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -370,22 +370,22 @@ limitations under the License.
     <a href="" class="lh-topbar__url" target="_blank" rel="noopener"></a>
 
     <div class="lh-tools">
-      <button class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu">
+      <button id="lh-tools-button" class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu" aria-haspopup="menu" aria-expanded="false" aria-controls="lh-tools-dropdown">
         <svg width="100%" height="100%" viewBox="0 0 24 24">
             <path d="M0 0h24v24H0z" fill="none"/>
             <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
         </svg>
       </button>
-      <div class="lh-tools__dropdown">
+      <div id="lh-tools-dropdown" role="menu" class="lh-tools__dropdown" aria-labelledby="lh-tools-button">
          <!-- TODO(i18n): localize tools dropdown -->
-        <a href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
-        <a href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
-        <a href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
-        <a href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
       </div>
     </div>
   </div>


Test patch:
diff --git a/lighthouse-core/test/gather/gatherers/start-url-test.js b/lighthouse-core/test/gather/gatherers/start-url-test.js
index 3868ec625..113b93dea 100644
--- a/lighthouse-core/test/gather/gatherers/start-url-test.js
+++ b/lighthouse-core/test/gather/gatherers/start-url-test.js
@@ -7,168 +7,220 @@
 
 /* eslint-env jest */
 
+jest.useFakeTimers();
+
 const StartUrlGatherer = require('../../../gather/gatherers/start-url.js');
 const parseManifest = require('../../../lib/manifest-parser.js');
-const assert = require('assert');
-
-const mockDriver = {
-  goOffline() {
-    return Promise.resolve();
-  },
-  goOnline() {
-    return Promise.resolve();
-  },
-  off() {},
-};
-
-const wrapSendCommand = (mockDriver, url, status = 200, fromServiceWorker = false) => {
-  mockDriver = Object.assign({}, mockDriver);
-  mockDriver.evaluateAsync = () => Promise.resolve();
-  mockDriver.on = (name, cb) => {
-    cb({response: {status, url, fromServiceWorker}});
-  };
-
-  return mockDriver;
-};
-
-describe('Start-url gatherer', () => {
-  let baseArtifacts;
+
+describe('StartUrl Gatherer', () => {
+  let mockDriver;
+  let gatherer;
 
   function createArtifactsWithURL(url) {
     return {WebAppManifest: {value: {start_url: {value: url}}}};
   }
 
+  function unimplemented() {
+    throw new Error('Unimplemented');
+  }
+
   beforeEach(() => {
-    jest.useFakeTimers();
-    baseArtifacts = {WebAppManifest: null};
+    gatherer = new StartUrlGatherer();
+    mockDriver = {
+      goOffline: unimplemented,
+      goOnline: unimplemented,
+      evaluateAsync: unimplemented,
+      on: unimplemented,
+      off: unimplemented,
+    };
   });
 
   afterEach(() => {
     jest.advanceTimersByTime(5000);
   });
 
-  it('returns an artifact set to -1 when offline loading fails', () => {
-    const startUrlGatherer = new StartUrlGatherer();
-    const startUrlGathererWithQueryString = new StartUrlGatherer();
-    const startUrlGathererWithResponseNotFromSW = new StartUrlGatherer();
-    const throwOnEvaluate = (mockDriver) => {
-      mockDriver.on = () => {};
-      mockDriver.evaluateAsync = () => {
-        throw new Error({
-          TypeError: 'Failed to fetch',
-          __failedInBrowser: true,
-          name: 'TypeError',
-          message: 'Failed to fetch',
-        });
-      };
-
-      return mockDriver;
-    };
+  it('returns a explanation when manifest cannot be found', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
 
     const passContext = {
-      url: 'https://do-not-match.com/',
-      baseArtifacts,
-      driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://do-not-match.com/', -1)),
+      baseArtifacts: {WebAppManifest: null},
+      driver: mockDriver,
     };
-    const passContextWithFragment = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/?history',
-      driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/?history', -1)),
+
+    const result = await gatherer.afterPass(passContext);
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'No usable web app manifest found on page.',
+    });
+  });
+
+  it('returns a explanation when manifest cannot be parsed', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+
+    const passContext = {
+      baseArtifacts: {
+        WebAppManifest: parseManifest(
+          'this is invalid',
+          'https://example.com/manifest.json',
+          'https://example.com/'
+        ),
+      },
+      driver: mockDriver,
     };
-    const passContextWithResponseNotFromSW = {
-      url: 'https://do-not-match.com/',
-      baseArtifacts: createArtifactsWithURL('https://do-not-match.com/'),
-      driver: wrapSendCommand(mockDriver, 'https://do-not-match.com/', 200),
+
+    const result = await gatherer.afterPass(passContext);
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation:
+        `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
+        `SyntaxError: Unexpected token h in JSON at position 1.`,
+    });
+  });
+
+  it('sets the status code to -1 when navigation fails', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockRejectedValue(new Error('Fetch failed'));
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
+    const passContext = {
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    return Promise.all([
-      startUrlGatherer.afterPass(passContext),
-      startUrlGathererWithQueryString.afterPass(passContextWithFragment),
-      startUrlGathererWithResponseNotFromSW.afterPass(passContextWithResponseNotFromSW),
-    ]).then(([artifact, artifactWithQueryString, artifactWithResponseNotFromSW]) => {
-      assert.equal(artifact.statusCode, -1);
-      assert.ok(artifact.explanation, 'did not set debug string');
-      assert.equal(artifactWithQueryString.statusCode, -1);
-      assert.ok(artifactWithQueryString.explanation, 'did not set debug string');
-      assert.equal(artifactWithResponseNotFromSW.statusCode, -1);
-      assert.equal(artifactWithResponseNotFromSW.explanation,
-          'Unable to fetch start URL via service worker.');
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'Error while fetching start_url via service worker.',
     });
   });
 
-  it('returns an artifact set to 200 when offline loading from service worker succeeds', () => {
-    const startUrlGatherer = new StartUrlGatherer();
-    const startUrlGathererWithFragment = new StartUrlGatherer();
+  it('sets the status code to page status code', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      url: 'https://ifixit-pwa.appspot.com/',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
-      driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/', 200, true),
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
-    const passContextWithFragment = {
-      url: 'https://ifixit-pwa.appspot.com/#/history',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/#/history'),
-      driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/#/history', 200, true),
+
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: true,
     };
 
-    return Promise.all([
-      startUrlGatherer.afterPass(passContext),
-      startUrlGathererWithFragment.afterPass(passContextWithFragment),
-    ]).then(([artifact, artifactWithFragment]) => {
-      assert.equal(artifact.statusCode, 200);
-      assert.equal(artifactWithFragment.statusCode, 200);
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: 200,
     });
   });
 
-  it('returns a explanation when manifest cannot be found', () => {
-    const driver = Object.assign({}, mockDriver);
-    const startUrlGatherer = new StartUrlGatherer();
+  it('sets the status code to -1 when not from service worker', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/',
-      driver,
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    return startUrlGatherer.afterPass(passContext)
-      .then(artifact => {
-        assert.equal(artifact.explanation,
-          `No usable web app manifest found on page.`);
-      });
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: false,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'The start_url did respond, but not via a service worker.',
+    });
   });
 
-  it('returns a explanation when manifest cannot be parsed', () => {
-    const driver = Object.assign({}, mockDriver);
-    const startUrlGatherer = new StartUrlGatherer();
+  it('sets the status code to -1 when times out waiting for a matching url', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/',
-      driver,
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    baseArtifacts.WebAppManifest = parseManifest(
-      'this is invalid',
-      'https://ifixit-pwa.appspot.com/manifest.json',
-      passContext.url
-    );
-
-    return startUrlGatherer.afterPass(passContext)
-      .then(artifact => {
-        assert.equal(artifact.explanation,
-          `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
-          `SyntaxError: Unexpected token h in JSON at position 1.`);
-      });
+    const response = {
+      url: 'https://random-other-url.com/',
+      status: 200,
+      fromServiceWorker: true,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const resultPromise = gatherer.afterPass(passContext);
+    // Wait a tick for the evaluateAsync promise resolution to hold for the timer.
+    await Promise.resolve();
+    // Skip past our timeout of 3000.
+    jest.advanceTimersByTime(5000);
+    const result = await resultPromise;
+
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'Timed out waiting for start_url to respond.',
+    });
   });
 
-  it('times out when a start_url is too slow to respond', async () => {
-    const startUrlGatherer = new StartUrlGatherer();
+  it('navigates *while* offline', async () => {
+    const callsAtNavigationTime = [];
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = async () => {
+      callsAtNavigationTime.push(
+        ...mockDriver.goOffline.mock.calls.map(() => 'offline'),
+        ...mockDriver.goOnline.mock.calls.map(() => 'online')
+      );
+    };
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      url: 'https://ifixit-pwa.appspot.com/',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
-      driver: wrapSendCommand(mockDriver, ''),
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    const resultPromise = startUrlGatherer.afterPass(passContext);
-    jest.advanceTimersByTime(5000);
-    const artifact = await resultPromise;
-    assert.equal(artifact.explanation, 'Timed out waiting for fetched start_url.');
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: true,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+    const result = await gatherer.afterPass(passContext);
+    expect(callsAtNavigationTime).toEqual(['offline']);
+    expect(result).toEqual({
+      statusCode: 200,
+    });
   });
 });
diff --git a/lighthouse-core/test/report/html/renderer/dom-test.js b/lighthouse-core/test/report/html/renderer/dom-test.js
index 39a35b960..63fc457c3 100644
--- a/lighthouse-core/test/report/html/renderer/dom-test.js
+++ b/lighthouse-core/test/report/html/renderer/dom-test.js
@@ -127,7 +127,14 @@ describe('DOM', () => {
       assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://developers.google.com/web/tools/lighthouse/audits/description?utm_source=lighthouse&amp;utm_medium=someChannel">Learn more</a>.');
     });
 
-    it('doesn\'t append utm params to non https://developers.google.com origins', () => {
+    it('appends utm params to the URLs with https://web.dev origin', () => {
+      const text = '[Learn more](https://web.dev/tap-targets/).';
+
+      const result = dom.convertMarkdownLinkSnippets(text);
+      assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://web.dev/tap-targets/?utm_source=lighthouse&amp;utm_medium=someChannel">Learn more</a>.');
+    });
+
+    it('doesn\'t append utm params to other (non-docs) origins', () => {
       const text = '[Learn more](https://example.com/info).';
 
       const result = dom.convertMarkdownLinkSnippets(text);
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 577cbb2b6..9f93127e7 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -73,6 +73,7 @@ describe('ReportUIFeatures', () => {
       };
     };
 
+    global.HTMLElement = document.window.HTMLElement;
     global.HTMLInputElement = document.window.HTMLInputElement;
 
     global.window = document.window;
@@ -99,6 +100,7 @@ describe('ReportUIFeatures', () => {
     global.PerformanceCategoryRenderer = undefined;
     global.PwaCategoryRenderer = undefined;
     global.window = undefined;
+    global.HTMLElement = undefined;
     global.HTMLInputElement = undefined;
   });
 
@@ -264,4 +266,130 @@ describe('ReportUIFeatures', () => {
       assert.ok(container.querySelector('.lh-metrics-toggle__input').checked);
     });
   });
+
+  describe('tools button', () => {
+    let window;
+    let features;
+
+    beforeEach(() => {
+      window = dom.document().defaultView;
+      features = new ReportUIFeatures(dom);
+      features.initFeatures(sampleResults);
+    });
+
+    it('click should toggle active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      features.toolsButton.click();
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+
+    it('Escape key removes active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      const escape = new window.KeyboardEvent('keydown', {keyCode: /* ESC */ 27});
+      dom.document().dispatchEvent(escape);
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+    ['ArrowUp', 'ArrowDown', 'Enter', ' '].forEach((code) => {
+      it(`'${code}' adds active class`, () => {
+        const event = new window.KeyboardEvent('keydown', {code});
+        features.toolsButton.dispatchEvent(event);
+        assert.ok(features.toolsButton.classList.contains('active'));
+      });
+    });
+
+    it('ArrowUp on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const arrowUp = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowUp'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowUp);
+
+      assert.strictEqual(dom.document().activeElement, features.toolsDropDown.lastElementChild);
+    });
+
+    it('ArrowDown on the first menu element should focus the second element', () => {
+      features.toolsButton.click();
+
+      const {nextElementSibling} = features.toolsDropDown.firstElementChild;
+      const arrowDown = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowDown'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowDown);
+
+      assert.strictEqual(dom.document().activeElement, nextElementSibling);
+    });
+
+    it('Home on the last menu element should focus the first element', () => {
+      features.toolsButton.click();
+
+      const {firstElementChild} = features.toolsDropDown;
+      const home = new window.KeyboardEvent('keydown', {bubbles: true, code: 'Home'});
+      features.toolsDropDown.lastElementChild.dispatchEvent(home);
+
+      assert.strictEqual(dom.document().activeElement, firstElementChild);
+    });
+
+    it('End on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const {lastElementChild} = features.toolsDropDown;
+      const end = new window.KeyboardEvent('keydown', {bubbles: true, code: 'End'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(end);
+
+      assert.strictEqual(dom.document().activeElement, lastElementChild);
+    });
+
+    describe('_getNextSelectableNode', () => {
+      let createDiv;
+
+      beforeAll(() => {
+        createDiv = () => dom.document().createElement('div');
+      });
+
+      it('should return first node when start is undefined', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should return second node when start is first node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[1]);
+      });
+
+      it('should return first node when start is second node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[1]);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should skip the undefined node', () => {
+        const nodes = [createDiv(), undefined, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+
+      it('should skip the disabled node', () => {
+        const disabledNode = createDiv();
+        disabledNode.setAttribute('disabled', true);
+        const nodes = [createDiv(), disabledNode, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+    });
+  });
 });