Repository: GoogleChrome/lighthouse (js)

Issue:
[SEO Audits] Tap targets are sized appropriately
**Audit group:** [Mobile friendly](https://github.com/GoogleChrome/lighthouse/issues/3116)
**Description:** Tap targets are sized appropriately
**Failure description:** Tap targets are not sized appropriately
**Help text:** Interactive elements like buttons and links should be large enough, and have enough space around them, to be easy enough to tap without overlapping onto other elements. Learn more.

**Success conditions:**
- all interactive elements are at least 48px tall and wide (content + padding)
- all interactive elements are at least 32px apart (margin)

If possible, the weighting for this audit should be proportional to the number of interactive element failures.

Interactive elements:
- button
- a
- input
- textarea
- select
- option
- [role=<[widget](https://www.w3.org/WAI/PF/aria/roles#widget_roles)>]

For reference, see: https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design

Pull request:
new_audit(tap-targets): check that tap targets are big enough and don't overlap
**Summary**
This audit checks if all potential tap targets (see 1) are sized appropriately (see 2) and are far enough from each other to be unambiguous (see 3).

**Limitations**
1) we take potential tap targets into account (`a, button, input, …`) - this list may not include some tap targets that are made "tappable" via JS (e.g. some div used as a button) and may include some tap targets that don't cause any action (e.g. dummy button that does nothing)
2) We define finger size to be 48x48px
3) Implemented algorithm is simpler from what Chrome does (e.g. Chrome is often able to figure out which link was clicked even if tap area covers two links). The algorithm boils down to checking if there is some other tap target located < 32px from the center of the currently audited one.

**Demo** (note that visual output is *not* part of this audit):

<img src='https://user-images.githubusercontent.com/985504/44219129-92c16f80-a17b-11e8-89af-1d5acce0b2e2.gif' height=450 />

**TODO**
- [ ] descriptions
- [x] linting
- [ ] tests

**Related Issues/PRs**
Closes #4358 


Patch:
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index 440ea50ec..591288ba9 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -212,7 +212,8 @@ class FontSize extends Audit {
     if (!hasViewportSet) {
       return {
         rawValue: false,
-        explanation: 'Text is illegible because of a missing viewport config',
+        explanation:
+          'Text is illegible because there\'s no viewport meta tag optimized for mobile screens',
       };
     }
 
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
new file mode 100644
index 000000000..d71dbe385
--- /dev/null
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -0,0 +1,326 @@
+/**
+ * @license Copyright 2018 Google Inc. 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';
+
+/**
+ * @fileoverview Checks that links, buttons, etc. are sufficiently large and that there's
+ * no other tap target that's too close so that the user might accidentally tap on.
+ */
+const Audit = require('../audit');
+const ViewportAudit = require('../viewport');
+const {
+  getRectOverlapArea,
+  getRectAtCenter,
+  allRectsContainedWithinEachOther,
+  getLargestRect,
+} = require('../../lib/rect-helpers');
+const {getTappableRectsFromClientRects} = require('../../lib/tappable-rects');
+
+const FINGER_SIZE_PX = 48;
+// Ratio of the finger area tapping on an unintended element
+// to the finger area tapping on the intended element
+const MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO = 0.25;
+
+
+/**
+ * @param {LH.Artifacts.Rect} cr
+ */
+function clientRectBelowMinimumSize(cr) {
+  return cr.width < FINGER_SIZE_PX || cr.height < FINGER_SIZE_PX;
+}
+
+/**
+ * A target is "too small" if none of its clientRects are at least the size of a finger.
+ * @param {LH.Artifacts.TapTarget[]} targets
+ * @returns {LH.Artifacts.TapTarget[]}
+ */
+function getTooSmallTargets(targets) {
+  return targets.filter(target => {
+    return target.clientRects.every(clientRectBelowMinimumSize);
+  });
+}
+
+/**
+ * @param {LH.Artifacts.TapTarget[]} tooSmallTargets
+ * @param {LH.Artifacts.TapTarget[]} allTargets
+ * @returns {TapTargetOverlapFailure[]}
+ */
+function getAllOverlapFailures(tooSmallTargets, allTargets) {
+  /** @type {TapTargetOverlapFailure[]} */
+  let failures = [];
+
+  tooSmallTargets.forEach(target => {
+    const overlappingTargets = getAllOverlapFailuresForTarget(
+      target,
+      allTargets
+    );
+
+    failures = failures.concat(overlappingTargets);
+  });
+
+  return failures;
+}
+
+/**
+ *
+ * @param {LH.Artifacts.TapTarget} tapTarget
+ * @param {LH.Artifacts.TapTarget[]} allTapTargets
+ * @returns {TapTargetOverlapFailure[]}
+ */
+function getAllOverlapFailuresForTarget(tapTarget, allTapTargets) {
+  /** @type TapTargetOverlapFailure[] */
+  const failures = [];
+
+  for (const maybeOverlappingTarget of allTapTargets) {
+    if (maybeOverlappingTarget === tapTarget) {
+      // checking the same target with itself, skip
+      continue;
+    }
+
+    const failure = getOverlapFailureForTargetPair(tapTarget, maybeOverlappingTarget);
+    if (failure) {
+      failures.push(failure);
+    }
+  }
+
+  return failures;
+}
+
+/**
+ * @param {LH.Artifacts.TapTarget} tapTarget
+ * @param {LH.Artifacts.TapTarget} maybeOverlappingTarget
+ * @returns {TapTargetOverlapFailure | null}
+ */
+function getOverlapFailureForTargetPair(tapTarget, maybeOverlappingTarget) {
+  const isHttpOrHttpsLink = /https?:\/\//.test(tapTarget.href);
+  if (isHttpOrHttpsLink && tapTarget.href === maybeOverlappingTarget.href) {
+    // no overlap because same target action
+    return null;
+  }
+
+  // Convert client rects to unique tappable areas from a user's perspective
+  const tappableRects = getTappableRectsFromClientRects(tapTarget.clientRects);
+  if (allRectsContainedWithinEachOther(tappableRects, maybeOverlappingTarget.clientRects)) {
+    // If one tap target is fully contained within the other that's
+    // probably intentional (e.g. an item with a delete button inside).
+    // We'll miss some problems because of this, but that's better
+    // than falsely reporting a failure.
+    return null;
+  }
+
+  /** @type TapTargetOverlapFailure | null */
+  let greatestFailure = null;
+  for (const targetCR of tappableRects) {
+    for (const maybeOverlappingCR of maybeOverlappingTarget.clientRects) {
+      const failure = getOverlapFailureForClientRectPair(targetCR, maybeOverlappingCR);
+      if (failure) {
+        // only update our state if this was the biggest failure we've seen for this pair
+        if (!greatestFailure ||
+          failure.overlapScoreRatio > greatestFailure.overlapScoreRatio) {
+          greatestFailure = {
+            ...failure,
+            tapTarget,
+            overlappingTarget: maybeOverlappingTarget,
+          };
+        }
+      }
+    }
+  }
+  return greatestFailure;
+}
+
+/**
+ * @param {LH.Artifacts.Rect} targetCR
+ * @param {LH.Artifacts.Rect} maybeOverlappingCR
+ * @returns {ClientRectOverlapFailure | null}
+ */
+function getOverlapFailureForClientRectPair(targetCR, maybeOverlappingCR) {
+  const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
+  // Score indicates how much of the finger area overlaps each target when the user
+  // taps on the center of targetCR
+  const tapTargetScore = getRectOverlapArea(fingerRect, targetCR);
+  const maybeOverlappingScore = getRectOverlapArea(fingerRect, maybeOverlappingCR);
+
+  const overlapScoreRatio = maybeOverlappingScore / tapTargetScore;
+  if (overlapScoreRatio < MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO) {
+    // low score means it's clear that the user tried to tap on the targetCR,
+    // rather than the other tap target client rect
+    return null;
+  }
+
+  return {
+    overlapScoreRatio,
+    tapTargetScore,
+    overlappingTargetScore: maybeOverlappingScore,
+  };
+}
+
+/**
+ * Only report one failure if two targets overlap each other
+ * @param {TapTargetOverlapFailure[]} overlapFailures
+ * @returns {TapTargetOverlapFailure[]}
+ */
+function mergeSymmetricFailures(overlapFailures) {
+  /** @type TapTargetOverlapFailure[] */
+  const failuresAfterMerging = [];
+
+  overlapFailures.forEach((failure, overlapFailureIndex) => {
+    const symmetricFailure = overlapFailures.find(f =>
+      f.tapTarget === failure.overlappingTarget &&
+      f.overlappingTarget === failure.tapTarget
+    );
+
+    if (!symmetricFailure) {
+      failuresAfterMerging.push(failure);
+      return;
+    }
+
+    const {overlapScoreRatio: failureOSR} = failure;
+    const {overlapScoreRatio: symmetricOSR} = symmetricFailure;
+    // Push if:
+    // - the current failure has a higher OSR
+    // - OSRs are the same, and the current failure comes before its symmetric partner in the list
+    // Otherwise do nothing and let the symmetric partner be pushed later.
+    if (failureOSR > symmetricOSR || (
+      failureOSR === symmetricOSR &&
+      overlapFailureIndex < overlapFailures.indexOf(symmetricFailure)
+    )) {
+      failuresAfterMerging.push(failure);
+    }
+  });
+
+  return failuresAfterMerging;
+}
+
+/**
+ * @param {TapTargetOverlapFailure[]} overlapFailures
+ * @returns {TapTargetTableItem[]}
+ */
+function getTableItems(overlapFailures) {
+  const tableItems = overlapFailures.map(failure => {
+    const largestCR = getLargestRect(failure.tapTarget.clientRects);
+    const width = Math.floor(largestCR.width);
+    const height = Math.floor(largestCR.height);
+    const size = width + 'x' + height;
+    return {
+      tapTarget: targetToTableNode(failure.tapTarget),
+      overlappingTarget: targetToTableNode(failure.overlappingTarget),
+      tapTargetScore: failure.tapTargetScore,
+      overlappingTargetScore: failure.overlappingTargetScore,
+      overlapScoreRatio: failure.overlapScoreRatio,
+      size,
+      width,
+      height,
+    };
+  });
+
+  tableItems.sort((a, b) => {
+    return b.overlapScoreRatio - a.overlapScoreRatio;
+  });
+
+  return tableItems;
+}
+
+/**
+ * @param {LH.Artifacts.TapTarget} target
+ * @returns {LH.Audit.DetailsRendererNodeDetailsJSON}
+ */
+function targetToTableNode(target) {
+  return {
+    type: 'node',
+    snippet: target.snippet,
+    path: target.path,
+    selector: target.selector,
+  };
+}
+
+class TapTargets extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'tap-targets',
+      title: 'Tap targets are sized appropriately',
+      failureTitle: 'Tap targets are not sized appropriately',
+      description:
+        'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).',
+      requiredArtifacts: ['MetaElements', 'TapTargets'],
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @return {LH.Audit.Product}
+   */
+  static audit(artifacts) {
+    const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
+    if (!hasViewportSet) {
+      return {
+        rawValue: false,
+        // eslint-disable-next-line
+        explanation: 'Tap targets are too small because there\'s no viewport meta tag optimized for mobile screens',
+      };
+    }
+
+    const tooSmallTargets = getTooSmallTargets(artifacts.TapTargets);
+    const overlapFailures = getAllOverlapFailures(tooSmallTargets, artifacts.TapTargets);
+    const overlapFailuresForDisplay = mergeSymmetricFailures(overlapFailures);
+    const tableItems = getTableItems(overlapFailuresForDisplay);
+
+    const headings = [
+      {key: 'tapTarget', itemType: 'node', text: 'Tap Target'},
+      {key: 'size', itemType: 'text', text: 'Size'},
+      {key: 'overlappingTarget', itemType: 'node', text: 'Overlapping Target'},
+    ];
+
+    const details = Audit.makeTableDetails(headings, tableItems);
+
+    const tapTargetCount = artifacts.TapTargets.length;
+    const failingTapTargetCount = new Set(overlapFailures.map(f => f.tapTarget)).size;
+    const passingTapTargetCount = tapTargetCount - failingTapTargetCount;
+
+    const score = tapTargetCount > 0 ? passingTapTargetCount / tapTargetCount : 1;
+    const displayValue = Math.round(score * 100) + '% appropriately sized tap targets';
+
+    return {
+      rawValue: tableItems.length === 0,
+      score,
+      details,
+      displayValue,
+    };
+  }
+}
+
+TapTargets.FINGER_SIZE_PX = FINGER_SIZE_PX;
+
+module.exports = TapTargets;
+
+
+/** @typedef {{
+  overlapScoreRatio: number;
+  tapTargetScore: number;
+  overlappingTargetScore: number;
+}} ClientRectOverlapFailure */
+
+/** @typedef {{
+  overlapScoreRatio: number;
+  tapTargetScore: number;
+  overlappingTargetScore: number;
+  tapTarget: LH.Artifacts.TapTarget;
+  overlappingTarget: LH.Artifacts.TapTarget;
+}} TapTargetOverlapFailure */
+
+/** @typedef {{
+  tapTarget: LH.Audit.DetailsRendererNodeDetailsJSON;
+  overlappingTarget: LH.Audit.DetailsRendererNodeDetailsJSON;
+  size: string;
+  overlapScoreRatio: number;
+  height: number;
+  width: number;
+  tapTargetScore: number;
+  overlappingTargetScore: number;
+}} TapTargetTableItem */
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 5fe0e3589..fb018545b 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -109,6 +109,7 @@ const defaultConfig = {
       'seo/embedded-content',
       'seo/canonical',
       'seo/robots-txt',
+      'seo/tap-targets',
     ],
   },
   {
@@ -242,6 +243,7 @@ const defaultConfig = {
     'seo/link-text',
     'seo/is-crawlable',
     'seo/robots-txt',
+    'seo/tap-targets',
     'seo/hreflang',
     'seo/plugins',
     'seo/canonical',
@@ -447,6 +449,7 @@ const defaultConfig = {
         {id: 'canonical', weight: 1, group: 'seo-content'},
         {id: 'font-size', weight: 1, group: 'seo-mobile'},
         {id: 'plugins', weight: 1, group: 'seo-content'},
+        {id: 'tap-targets', weight: 1, group: 'seo-mobile'},
         // Manual audits
         {id: 'mobile-friendly', weight: 0},
         {id: 'structured-data', weight: 0},
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
new file mode 100644
index 000000000..bcf1fd45a
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -0,0 +1,336 @@
+/**
+ * @license Copyright 2018 Google Inc. 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';
+
+/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector */
+
+const Gatherer = require('../gatherer');
+const pageFunctions = require('../../../lib/page-functions.js');
+const {rectContainsString, rectContains} = require('../../../lib/rect-helpers');
+
+const TARGET_SELECTORS = [
+  'button',
+  'a',
+  'input',
+  'textarea',
+  'select',
+  'option',
+  '[role=button]',
+  '[role=checkbox]',
+  '[role=link]',
+  '[role=menuitem]',
+  '[role=menuitemcheckbox]',
+  '[role=menuitemradio]',
+  '[role=option]',
+  '[role=scrollbar]',
+  '[role=slider]',
+  '[role=spinbutton]',
+];
+const tapTargetsSelector = TARGET_SELECTORS.join(',');
+
+/**
+ * @param {Element} element
+ * @returns {boolean}
+ */
+/* istanbul ignore next */
+function elementIsVisible(element) {
+  const {overflowX, overflowY, display, visibility} = getComputedStyle(element);
+
+  if (
+    display === 'none' ||
+    (visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(element.tagName))
+  ) {
+    // Element not displayed
+    return false;
+  }
+
+  // only for block and inline-block, since clientWidth/Height are always 0 for inline elements
+  if (display === 'block' || display === 'inline-block') {
+    // if height/width is 0 and no overflow in that direction then
+    // there's no content that the user can see and tap on
+    if ((element.clientWidth === 0 && overflowX === 'hidden') ||
+        (element.clientHeight === 0 && overflowY === 'hidden')) {
+      return false;
+    }
+  }
+
+  const parent = element.parentElement;
+  if (parent && parent.tagName !== 'BODY') {
+    // if a parent is invisible then the current element is also invisible
+    return elementIsVisible(parent);
+  }
+
+  return true;
+}
+
+/**
+ * @param {LH.Artifacts.Rect[]} clientRects
+ */
+/* istanbul ignore next */
+function allClientRectsEmpty(clientRects) {
+  return clientRects.every(cr => cr.width === 0 && cr.height === 0);
+}
+
+/**
+ * @param {Element} element
+ */
+/* istanbul ignore next */
+function getVisibleClientRects(element) {
+  if (!elementIsVisible(element)) {
+    return [];
+  }
+
+  let clientRects = getClientRects(element);
+
+  if (allClientRectsEmpty(clientRects)) {
+    return [];
+  }
+
+  // Treating overflowing content in scroll containers as invisible could mean that
+  // most of a given page is deemed invisible. But:
+  // - tap targets audit doesn't consider different containers/layers
+  // - having most content in an explicit scroll container is rare
+  // - treating them as hidden only generates false passes, which is better than false failures
+  clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects);
+
+  return clientRects;
+}
+
+/**
+ *
+ * @param {Element} element
+ * @param {LH.Artifacts.Rect[]} clientRects
+ * @returns {LH.Artifacts.Rect[]}
+ */
+/* istanbul ignore next */
+function filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects) {
+  const parent = element.parentElement;
+  if (!parent) {
+    return clientRects;
+  }
+  if (getComputedStyle(parent).overflowY !== 'visible') {
+    const parentBCR = parent.getBoundingClientRect();
+    clientRects = clientRects.filter(cr => rectContains(parentBCR, cr));
+  }
+  if (parent.parentElement && parent.parentElement.tagName !== 'BODY') {
+    return filterClientRectsWithinAncestorsVisibleScrollArea(
+      parent,
+      clientRects
+    );
+  }
+  return clientRects;
+}
+
+/**
+ * @param {Element} element
+ * @returns {LH.Artifacts.Rect[]}
+ */
+/* istanbul ignore next */
+function getClientRects(element) {
+  const clientRects = Array.from(
+    element.getClientRects()
+  ).map(clientRect => {
+    // Contents of DOMRect get lost when returned from Runtime.evaluate call,
+    // so we convert them to plain objects.
+    const {width, height, left, top, right, bottom} = clientRect;
+    return {width, height, left, top, right, bottom};
+  });
+
+  for (const child of element.children) {
+    clientRects.push(...getClientRects(child));
+  }
+
+  return clientRects;
+}
+
+/**
+ * @param {Element} element
+ * @returns {boolean}
+ */
+/* istanbul ignore next */
+function elementHasAncestorTapTarget(element) {
+  if (!element.parentElement) {
+    return false;
+  }
+  if (element.parentElement.matches(tapTargetsSelector)) {
+    return true;
+  }
+  return elementHasAncestorTapTarget(element.parentElement);
+}
+
+/**
+ * @param {Element} element
+ */
+/* istanbul ignore next */
+function hasTextNodeSiblingsFormingTextBlock(element) {
+  if (!element.parentElement) {
+    return false;
+  }
+
+  const parentElement = element.parentElement;
+
+  const nodeText = element.textContent || '';
+  const parentText = parentElement.textContent || '';
+  if (parentText.length - nodeText.length < 5) {
+    // Parent text mostly consists of this node, so the parent
+    // is not a text block container
+    return false;
+  }
+
+  for (const sibling of element.parentElement.childNodes) {
+    if (sibling === element) {
+      continue;
+    }
+    const siblingTextContent = (sibling.textContent || '').trim();
+    // Only count text in text nodes so that a series of e.g. buttons isn't counted
+    // as a text block.
+    // This works reasonably well, but means we miss text blocks where all text is e.g.
+    // wrapped in spans
+    if (sibling.nodeType === Node.TEXT_NODE && siblingTextContent.length > 0) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+/**
+ * Check if element is in a block of text, such as paragraph with a bunch of links in it.
+ * Makes a reasonable guess, but for example gets it wrong if the element is surrounded by other
+ * HTML elements instead of direct text nodes.
+ * @param {Element} element
+ * @returns {boolean}
+ */
+/* istanbul ignore next */
+function elementIsInTextBlock(element) {
+  const {display} = getComputedStyle(element);
+  if (display !== 'inline' && display !== 'inline-block') {
+    return false;
+  }
+
+  if (hasTextNodeSiblingsFormingTextBlock(element)) {
+    return true;
+  } else if (element.parentElement) {
+    return elementIsInTextBlock(element.parentElement);
+  } else {
+    return false;
+  }
+}
+
+/**
+ * @param {Element} element
+ * @returns {boolean}
+ */
+/* istanbul ignore next */
+function elementIsPositionFixedOrAbsolute(element) {
+  const {position} = getComputedStyle(element);
+  if (position === 'fixed' || position === 'absolute') {
+    return true;
+  }
+  if (element.parentElement) {
+    return elementIsPositionFixedOrAbsolute(element.parentElement);
+  }
+  return false;
+}
+
+/**
+ * @param {string} str
+ * @param {number} maxLength
+ * @returns {string}
+ */
+/* istanbul ignore next */
+function truncate(str, maxLength) {
+  if (str.length <= maxLength) {
+    return str;
+  }
+  return str.slice(0, maxLength - 1) + '…';
+}
+
+/**
+ * @returns {LH.Artifacts.TapTarget[]}
+ */
+/* istanbul ignore next */
+function gatherTapTargets() {
+  /** @type {LH.Artifacts.TapTarget[]} */
+  const targets = [];
+
+  /** @type {Element[]} */
+  // @ts-ignore - getElementsInDocument put into scope via stringification
+  const tapTargetElements = getElementsInDocument(tapTargetsSelector);
+
+  tapTargetElements.forEach(tapTargetElement => {
+    // Filter out tap targets that are likely to cause false failures:
+    if (elementHasAncestorTapTarget(tapTargetElement)) {
+      // This is usually intentional, either the tap targets trigger the same action
+      // or there's a child with a related action (like a delete button for an item)
+      return;
+    }
+    if (elementIsInTextBlock(tapTargetElement)) {
+      // Links inside text blocks cause a lot of failures, and there's also an exception for them
+      // in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
+      return;
+    }
+    if (elementIsPositionFixedOrAbsolute(tapTargetElement)) {
+      // Absolutely positioned elements might not be visible if they have a lower z-index
+      // than other tap targets, but if we don't ignore them we can get false failures.
+      //
+      // TODO: rewrite logic to use elementFromPoint to check if an element is visible
+      // (this should also mean we'd no longer need to check ancestor visible scroll area)
+      return;
+    }
+
+    const visibleClientRects = getVisibleClientRects(tapTargetElement);
+    if (visibleClientRects.length === 0) {
+      return;
+    }
+
+    targets.push({
+      clientRects: visibleClientRects,
+      snippet: truncate(tapTargetElement.outerHTML, 300),
+      // @ts-ignore - getNodePath put into scope via stringification
+      path: getNodePath(tapTargetElement),
+      // @ts-ignore - getNodeSelector put into scope via stringification
+      selector: getNodeSelector(tapTargetElement),
+      href: /** @type {HTMLAnchorElement} */(tapTargetElement)['href'] || '',
+    });
+  });
+
+  return targets;
+}
+
+class TapTargets extends Gatherer {
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts.TapTarget[]>} All visible tap targets with their positions and sizes
+   */
+  afterPass(passContext) {
+    const expression = `(function() {
+      const tapTargetsSelector = "${tapTargetsSelector}";
+      ${pageFunctions.getElementsInDocumentString};
+      ${filterClientRectsWithinAncestorsVisibleScrollArea.toString()};
+      ${elementIsPositionFixedOrAbsolute.toString()};
+      ${elementIsVisible.toString()};
+      ${elementHasAncestorTapTarget.toString()};
+      ${getVisibleClientRects.toString()};
+      ${truncate.toString()};
+      ${getClientRects.toString()};
+      ${hasTextNodeSiblingsFormingTextBlock.toString()};
+      ${elementIsInTextBlock.toString()};
+      ${allClientRectsEmpty.toString()};
+      ${rectContainsString};
+      ${pageFunctions.getNodePathString};
+      ${pageFunctions.getNodeSelectorString};
+      ${gatherTapTargets.toString()};
+      
+      return gatherTapTargets();
+    
+    })()`;
+
+    return passContext.driver.evaluateAsync(expression, {useIsolation: true});
+  }
+}
+
+module.exports = TapTargets;
diff --git a/lighthouse-core/lib/tappable-rects.js b/lighthouse-core/lib/tappable-rects.js
index 118d87916..c060fb2ad 100644
--- a/lighthouse-core/lib/tappable-rects.js
+++ b/lighthouse-core/lib/tappable-rects.js
@@ -30,13 +30,15 @@ function getTappableRectsFromClientRects(clientRects) {
 }
 
 /**
+ * Sometimes a child will reach out of the parent by a few px, but still
+ * clearly belong to the same tap area in the users's eyes.
+ * We can be quite generous here, since merging too much tends to cause false
+ * passes instead of false failures (because there are more fingers)
  * @param {number} a
  * @param {number} b
  */
 function almostEqual(a, b) {
-  // Sometimes a child will reach out of the parent by
-  // 1px or 2px, so be somewhat tolerant for merging
-  return Math.abs(a - b) <= 2;
+  return Math.abs(a - b) <= 10;
 }
 
 /**
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index fa934a87a..cd98245bc 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -1984,6 +1984,14 @@
             "scoreDisplayMode": "notApplicable", 
             "title": "No element has a `[tabindex]` value greater than 0"
         }, 
+        "tap-targets": {
+            "description": "Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).", 
+            "errorMessage": "Required TapTargets gatherer did not run.", 
+            "id": "tap-targets", 
+            "score": null, 
+            "scoreDisplayMode": "error", 
+            "title": "Tap targets are not sized appropriately"
+        }, 
         "td-headers-attr": {
             "description": "Screen readers have features to make navigating tables easier. Ensuring `<td>` cells using the `[headers]` attribute only refer to other cells in the same table may improve the experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/3.1/td-headers-attr?application=lighthouse).", 
             "id": "td-headers-attr", 
@@ -3220,6 +3228,11 @@
                     "id": "plugins", 
                     "weight": 1.0
                 }, 
+                {
+                    "group": "seo-mobile", 
+                    "id": "tap-targets", 
+                    "weight": 1.0
+                }, 
                 {
                     "id": "mobile-friendly", 
                     "weight": 0.0
@@ -4192,6 +4205,12 @@
                 "name": "lh:audit:robots-txt", 
                 "startTime": 0.0
             }, 
+            {
+                "duration": 100.0, 
+                "entryType": "measure", 
+                "name": "lh:audit:tap-targets", 
+                "startTime": 0.0
+            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index e2c3e55b1..a40bc276a 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -115,6 +115,8 @@ declare global {
       StartUrl: {statusCode: number, explanation?: string};
       /** Information on <script> and <link> tags blocking first paint. */
       TagsBlockingFirstPaint: Artifacts.TagBlockingFirstPaint[];
+      /** Information about tap targets including their position and size. */
+      TapTargets: Artifacts.TapTarget[];
       /** The dimensions and devicePixelRatio of the loaded viewport. */
       ViewportDimensions: Artifacts.ViewportDimensions;
     }
@@ -296,6 +298,14 @@ declare global {
         left: number;
       }
 
+      export interface TapTarget {
+        snippet: string,
+        selector: string,
+        path: string,
+        href: string,
+        clientRects: Rect[]
+      }
+
       export interface ViewportDimensions {
         innerWidth: number;
         innerHeight: number;
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 312895312..1fb2b36ff 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -76,11 +76,15 @@ declare global {
       wastedBytes?: number;
     }
 
+    export interface DetailsObject {
+      [x: string]: DetailsItem
+    }
+
     // TODO: placeholder typedefs until Details are typed
     export interface DetailsRendererDetailsJSON {
       type: 'table';
       headings: Array<Audit.Heading>;
-      items: Array<{[x: string]: DetailsItem}>;
+      items: Array<DetailsObject>;
       summary?: DetailsRendererDetailsSummary;
     }
 


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 064368476..e8da280b9 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -342,6 +342,9 @@ Object {
     Object {
       "path": "seo/robots-txt",
     },
+    Object {
+      "path": "seo/tap-targets",
+    },
     Object {
       "path": "seo/hreflang",
     },
@@ -946,6 +949,11 @@ Object {
           "id": "plugins",
           "weight": 1,
         },
+        Object {
+          "group": "seo-mobile",
+          "id": "tap-targets",
+          "weight": 1,
+        },
         Object {
           "id": "mobile-friendly",
           "weight": 0,
@@ -1107,6 +1115,9 @@ Object {
         Object {
           "path": "seo/robots-txt",
         },
+        Object {
+          "path": "seo/tap-targets",
+        },
       ],
       "networkQuietThresholdMs": 1000,
       "passName": "defaultPass",
diff --git a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
new file mode 100644
index 000000000..9db7c9934
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
@@ -0,0 +1,120 @@
+<!doctype html>
+<!--
+ * Copyright 2018 Google Inc. 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>
+<head>
+  <title>SEO tap target audit tester</title>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
+  <style>
+  a {
+    color: blue;
+  }
+  body {
+    margin: 0;
+  }
+  </style>
+  <script>
+    // Ensure gatherers still work when the prototype is messed with
+    HTMLElement.prototype.matches = { value: "blahblah" };
+  </script>
+</head>
+<body>
+  <!-- Big tap target, but it's invisible because it's behind the main content div -->
+  <a style="background: red; position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: -1;"></a>
+  <div style="background: white; padding: 20px;">
+    <h1>SEO Tap targets</h1>
+
+    <!-- Invisible nodes don't cause failures -->
+    <div>
+      <!-- Scroll container hides the child -->
+      <div style="height: 1px; overflow: auto;">
+        <a style="display: inline-block; height: 1000px; width: 1000px"></a>
+      </div>
+      <!-- Various invisible tap targets-->
+      <a></a>
+      <a style="width: 0;height:0;display:inline-block;">0</a>
+      <a style="display: none">display none</a>
+      <span style="display: none"><a>display none parent</a></span>
+      <a style="display: inline-block; width: 0; overflow-x: hidden;height: 1px">
+        width 0 and overflow x hidden
+      </a>
+      <!-- Visible target should not fail because nothing overlaps it -->
+      <a>visible target</a>
+    </div>
+    <br/><br/>
+
+    
+    <div style="overflow: hidden; position: relative">
+      <!-- Should be counted as visible although part of it is outside the scroll container -->
+      <a>
+        <div style="position: absolute;top: -100px">invisible</div>
+        visible
+      </a>
+    </div>
+
+    <br/><br/>
+
+    <!-- Link contains large inline block element - no failure because finger
+      should be placed in the center of the whole link area -->
+    <a style="background: red;">
+      <div style="display: inline-block; height: 30px; width: 30px; background: orange;"></div>
+      Link
+    </a>
+    <br/>
+    <a style="display: block; padding: 30px; background: #ddf;">
+      Link that the top one would overlap with, if it weren't for the inline-block child.
+    </a>
+
+    <br/><br/>
+
+    <div role="button">
+      Tap target with children that are also tap targets should not fail.
+      (Children should not be counted as independent tap targets that appear
+      in the list.)
+      Two children to make sure the two children also don't conflict with each other:
+      <a>Child 1</a><a>Child2</a>
+    </div>
+
+    <br/><br/>
+
+    <div style="position: relative">
+      <a style="display: block; position: absolute; top:0; height: 40px; width: 40px;">inner</a>
+      <a style="display: block; height: 100px; width: 100px; background: yellowgreen">outer</a>
+    </div>
+
+    <br/><br/>
+
+    <!-- Only target that's being overlapped should fail, the overlapping one shouldn't -->
+    <div>
+      <a style="display: block; width: 100px; height: 30px;background: #ddd;">
+        too small target
+      </a>
+      <a style="display: block; width: 100px; height: 100px;background: #aaa;">
+        big enough target
+      </a>
+    </div>
+
+    <br/><br/>
+
+    <!-- Should not fail if the two links have the same link target -->
+    <div>
+      <a style="display: block; width: 10px; height: 10px;" href="../seo/"></a>
+      <a style="display: block; width: 10px; height: 10px;" href="./"></a>
+    </div>
+
+    <!-- Links in text blocks are exempted from size/overlap requirements and should not fail -->
+    <p style="width: 100px">
+      This is <a>a link in a text block.</a>
+      This is <a>a link in a text block.</a>
+      This is <a>a link in a text block.</a>
+      This is <a>a link in a text block.</a>
+      This is <a>a link in a text block.</a>
+    </p>
+  </div>
+</body>
+</html>
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index a1f6269dc..06197e253 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -99,7 +99,8 @@ module.exports = [
       },
       'font-size': {
         rawValue: false,
-        explanation: 'Text is illegible because of a missing viewport config',
+        explanation:
+          'Text is illegible because there\'s no viewport meta tag optimized for mobile screens',
       },
       'link-text': {
         score: 0,
@@ -178,4 +179,41 @@ module.exports = [
       },
     },
   },
+  {
+    requestedUrl: BASE_URL + 'seo-tap-targets.html',
+    finalUrl: BASE_URL + 'seo-tap-targets.html',
+    audits: {
+      'tap-targets': {
+        score: 0.9, // 10 passing targets/11 total visible targets
+        details: {
+          items: [
+            {
+              'tapTarget': {
+                'type': 'node',
+                'snippet': '<a ' +
+                 'style="display: block; width: 100px; height: 30px;background: #ddd;">' +
+                 '\n        too small target\n      </a>',
+                'path': '2,HTML,1,BODY,2,DIV,21,DIV,0,A',
+                'selector': 'body > div > div > a',
+              },
+              'overlappingTarget': {
+                'type': 'node',
+                'snippet': '<a ' +
+                  'style="display: block; width: 100px; height: 100px;background: #aaa;">' +
+                  '\n        big enough target\n      </a>',
+                'path': '2,HTML,1,BODY,2,DIV,21,DIV,1,A',
+                'selector': 'body > div > div > a',
+              },
+              'size': '100x30',
+              'width': 100,
+              'height': 30,
+              'tapTargetScore': 1440,
+              'overlappingTargetScore': 432,
+              'overlapScoreRatio': 0.3,
+            },
+          ],
+        },
+      },
+    },
+  },
 ];
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index bd33d1843..f6513a045 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -25,7 +25,7 @@ describe('SEO: Font size audit', () => {
 
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('missing viewport'));
+    assert.ok(auditResult.explanation.includes('no viewport meta tag'));
   });
 
   it('fails when less than 60% of text is legible', () => {
diff --git a/lighthouse-core/test/audits/seo/tap-targets-test.js b/lighthouse-core/test/audits/seo/tap-targets-test.js
new file mode 100644
index 000000000..22a1b9a65
--- /dev/null
+++ b/lighthouse-core/test/audits/seo/tap-targets-test.js
@@ -0,0 +1,207 @@
+/**
+ * @license Copyright 2018 Google Inc. 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';
+
+/* eslint-env jest */
+
+const TapTargetsAudit = require('../../../audits/seo/tap-targets.js');
+const assert = require('assert');
+
+function auditTapTargets(tapTargets, metaElements = [{
+  name: 'viewport',
+  content: 'width=device-width',
+}]) {
+  const artifacts = {
+    TapTargets: tapTargets,
+    MetaElements: metaElements,
+  };
+
+  return TapTargetsAudit.audit(artifacts);
+}
+
+const tapTargetSize = 10;
+const minimalOverlapCausingDistance = (TapTargetsAudit.FINGER_SIZE_PX - tapTargetSize) / 2;
+// 3px means it'll have 10x3=30px overlap with the finger, which is 30% of the tap targets own score
+// and the failure cutoff is 25%
+const pxOverlappedByFinger = 3;
+const minimalFailingOverlapCausingDistance = minimalOverlapCausingDistance + pxOverlappedByFinger;
+
+function getBorderlineTapTargets(options = {}) {
+  function makeClientRects({x, y}) {
+    return {
+      left: x,
+      top: y,
+      width: tapTargetSize,
+      height: tapTargetSize,
+      bottom: y + tapTargetSize,
+      right: x + tapTargetSize,
+    };
+  }
+
+  const mainTapTarget = {
+    snippet: '<main></main>',
+    clientRects: [
+      makeClientRects({
+        x: 0,
+        y: 0,
+      }),
+    ],
+  };
+  const tapTargetBelow = {
+    snippet: '<below></below>',
+    clientRects: [
+      makeClientRects({
+        x: 0,
+        y: mainTapTarget.clientRects[0].top + TapTargetsAudit.FINGER_SIZE_PX,
+      }),
+    ],
+  };
+  const tapTargetToTheRight = {
+    snippet: '<right></right>',
+    clientRects: [
+      makeClientRects({
+        x: mainTapTarget.clientRects[0].left + TapTargetsAudit.FINGER_SIZE_PX,
+        y: 0,
+      }),
+    ],
+  };
+
+  if (options.reduceRightWidth) {
+    tapTargetToTheRight.clientRects[0].width -= 1;
+    tapTargetToTheRight.clientRects[0].right -= 1;
+  }
+  if (options.increaseRightWidth) {
+    tapTargetToTheRight.clientRects[0].width += 10;
+    tapTargetToTheRight.clientRects[0].right += 10;
+  }
+
+  const targets = [mainTapTarget, tapTargetBelow, tapTargetToTheRight];
+
+  const overlapAmount = minimalFailingOverlapCausingDistance;
+  if (options.overlapRight) {
+    tapTargetToTheRight.clientRects[0].left -= overlapAmount;
+    tapTargetToTheRight.clientRects[0].right -= overlapAmount;
+  }
+  if (options.overlapBelow) {
+    tapTargetBelow.clientRects[0].top -= overlapAmount;
+    tapTargetBelow.clientRects[0].bottom -= overlapAmount;
+  }
+  if (options.addFullyContainedTapTarget) {
+    targets.push({
+      snippet: '<contained></contained>',
+      clientRects: [
+        makeClientRects({
+          x: 0,
+          y: 0,
+        }),
+      ],
+    });
+  }
+  if (options.overlapSecondClientRect) {
+    mainTapTarget.clientRects.push(
+      makeClientRects({
+        x: 0,
+        y: overlapAmount,
+      })
+    );
+  }
+
+  return targets;
+}
+
+describe('SEO: Tap targets audit', () => {
+  it('passes when there are no tap targets', () => {
+    const auditResult = auditTapTargets([]);
+    assert.equal(auditResult.rawValue, true);
+    assert.equal(auditResult.score, 1);
+  });
+
+  it('passes when tap targets don\'t overlap', () => {
+    const auditResult = auditTapTargets(getBorderlineTapTargets());
+    assert.equal(auditResult.rawValue, true);
+  });
+
+  it('passes when a target is fully contained in an overlapping target', () => {
+    const auditResult = auditTapTargets(getBorderlineTapTargets({
+      addFullyContainedTapTarget: true,
+    }));
+    assert.equal(auditResult.rawValue, true);
+  });
+
+  it('fails if two tap targets overlaps each other horizontally', () => {
+    const auditResult = auditTapTargets(
+      getBorderlineTapTargets({
+        overlapRight: true,
+      })
+    );
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(Math.round(auditResult.score * 100), 33);
+    const failure = auditResult.details.items[0];
+    assert.equal(failure.tapTarget.snippet, '<main></main>');
+    assert.equal(failure.overlappingTarget.snippet, '<right></right>');
+    assert.equal(failure.size, '10x10');
+    // Includes data for debugging/adjusting the scoring logic later on
+    assert.equal(failure.tapTargetScore, tapTargetSize * tapTargetSize);
+    assert.equal(failure.overlappingTargetScore, tapTargetSize * pxOverlappedByFinger);
+    assert.equal(failure.overlapScoreRatio, 0.3);
+    assert.equal(failure.width, 10);
+    assert.equal(failure.height, 10);
+  });
+
+  it('fails if a tap target overlaps vertically', () => {
+    const auditResult = auditTapTargets(
+      getBorderlineTapTargets({
+        overlapBelow: true,
+      })
+    );
+    assert.equal(auditResult.rawValue, false);
+  });
+
+  it('fails when one of the client rects overlaps', () => {
+    const auditResult = auditTapTargets(
+      getBorderlineTapTargets({
+        overlapSecondClientRect: true,
+      })
+    );
+    assert.equal(auditResult.rawValue, false);
+  });
+
+  it('reports 2 items if the main target is overlapped both vertically and horizontally', () => {
+    // Main is overlapped by right + below, right and below are each overlapped by main
+    const auditResult = auditTapTargets(
+      getBorderlineTapTargets({
+        overlapRight: true,
+        reduceRightWidth: true,
+        overlapBelow: true,
+      })
+    );
+    assert.equal(Math.round(auditResult.score * 100), 0); // all tap targets are overlapped by something
+    const failures = auditResult.details.items;
+    assert.equal(failures.length, 2);
+    // Right and Main overlap each other, but Right has a worse score because it's smaller
+    // so it's the failure that appears in the report
+    assert.equal(failures[0].tapTarget.snippet, '<right></right>');
+  });
+
+  it('reports 1 failure if only one tap target involved in an overlap fails', () => {
+    const auditResult = auditTapTargets(
+      getBorderlineTapTargets({
+        overlapRight: true,
+        increaseRightWidth: true,
+      })
+    );
+    assert.equal(Math.round(auditResult.score * 100), 67);
+    const failures = auditResult.details.items;
+    // <main> fails, but <right> doesn't
+    assert.equal(failures[0].tapTarget.snippet, '<main></main>');
+  });
+
+  it('fails if no meta viewport tag is provided', () => {
+    const auditResult = auditTapTargets([], []);
+    assert.equal(auditResult.rawValue, false);
+    assert.ok(auditResult.explanation.includes('no viewport meta tag'));
+  });
+});
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index b47b0931d..484f0395a 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2723,6 +2723,15 @@
       "rawValue": null,
       "errorMessage": "Required RobotsTxt gatherer did not run."
     },
+    "tap-targets": {
+      "id": "tap-targets",
+      "title": "Tap targets are not sized appropriately",
+      "description": "Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).",
+      "score": null,
+      "scoreDisplayMode": "error",
+      "rawValue": null,
+      "errorMessage": "Required TapTargets gatherer did not run."
+    },
     "hreflang": {
       "id": "hreflang",
       "title": "Document has a valid `hreflang`",
@@ -3318,6 +3327,11 @@
           "weight": 1,
           "group": "seo-content"
         },
+        {
+          "id": "tap-targets",
+          "weight": 1,
+          "group": "seo-mobile"
+        },
         {
           "id": "mobile-friendly",
           "weight": 0
@@ -4332,6 +4346,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:tap-targets",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:hreflang",
diff --git a/lighthouse-viewer/test/viewer-test-pptr.js b/lighthouse-viewer/test/viewer-test-pptr.js
index cc3a98e09..71c097ab3 100644
--- a/lighthouse-viewer/test/viewer-test-pptr.js
+++ b/lighthouse-viewer/test/viewer-test-pptr.js
@@ -134,7 +134,8 @@ describe('Lighthouse Viewer', function() {
     const auditErrors = await viewerPage.$$eval(errorSelectors, getErrors, selectors);
     const errors = auditErrors.filter(item => item.explanation.includes('Audit error:'));
     const unexpectedErrrors = errors.filter(item => {
-      return !item.explanation.includes('Required RobotsTxt gatherer did not run');
+      return !item.explanation.includes('Required RobotsTxt gatherer did not run') &&
+        !item.explanation.includes('Required TapTargets gatherer did not run');
     });
     assert.deepStrictEqual(unexpectedErrrors, [], 'Audit errors found within the report');
   });