Repository: GoogleChrome/lighthouse
Pull request #9554 (2019-08-14 01:11:29)

clients(proto): don't filter channel from configSettings
<!-- Thank you for submitting a pull request! -->

**Summary**
`channel` never made the roundtrip!  And 'lr' got lost in the process.


exterkamp: > I don't suppose there's anything else missing while we're in here? everything else looks OK to me

I'm doing integration testing now with a fine toothed comb.  So...I don't know yet!  But we'll see!

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/lib/proto-preprocessor.js b/lighthouse-core/lib/proto-preprocessor.js
index 7f5142b95..6b7e943b4 100644
--- a/lighthouse-core/lib/proto-preprocessor.js
+++ b/lighthouse-core/lib/proto-preprocessor.js
@@ -29,10 +29,10 @@ function processForProto(lhr) {
   // 'ignore unknown fields' in the language of conversion.
   if (reportJson.configSettings) {
     // The settings that are in both proto and LHR
-    const {emulatedFormFactor, locale, onlyCategories} = reportJson.configSettings;
+    const {emulatedFormFactor, locale, onlyCategories, channel} = reportJson.configSettings;
 
     // @ts-ignore - intentionally only a subset of settings.
-    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories};
+    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories, channel};
   }
 
   // Remove runtimeError if it is NO_ERROR
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>
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index b26c834e3..a75d96605 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3947,6 +3947,7 @@
         }
     },
     "configSettings": {
+        "channel": "cli",
         "emulatedFormFactor": "mobile",
         "locale": "en-US",
         "onlyCategories": null


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]);
+      });
+    });
+  });
 });