Repository: GoogleChrome/lighthouse
Pull request #5857 (2018-08-17 20:34:19)

core: enhanced noopener output
**Summary**
cleans up TODO for including outerHTML in noopener link audit and does some related page-functions/dom-helpers cleanup along the way :)


Patch:
diff --git a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
index 131789cea..d518d8a7e 100644
--- a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
+++ b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
@@ -33,16 +33,12 @@ class ExternalAnchorsUseRelNoopenerAudit extends Audit {
     const warnings = [];
     const pageHost = new URL(artifacts.URL.finalUrl).host;
     // Filter usages to exclude anchors that are same origin
-    // TODO: better extendedInfo for anchors with no href attribute:
-    // https://github.com/GoogleChrome/lighthouse/issues/1233
-    // https://github.com/GoogleChrome/lighthouse/issues/1345
     const failingAnchors = artifacts.AnchorsWithNoRelNoopener
       .filter(anchor => {
         try {
           return new URL(anchor.href).host !== pageHost;
         } catch (err) {
-          // TODO(phulce): make this message better with anchor.outerHTML
-          warnings.push('Unable to determine the destination for anchor tag. ' +
+          warnings.push(`Unable to determine the destination for anchor (${anchor.outerHTML}). ` +
             'If not used as a hyperlink, consider removing target=_blank.');
           return true;
         }
@@ -55,10 +51,7 @@ class ExternalAnchorsUseRelNoopenerAudit extends Audit {
           href: anchor.href || 'Unknown',
           target: anchor.target || '',
           rel: anchor.rel || '',
-          url: '<a' +
-              (anchor.href ? ` href="${anchor.href}"` : '') +
-              (anchor.target ? ` target="${anchor.target}"` : '') +
-              (anchor.rel ? ` rel="${anchor.rel}"` : '') + '>',
+          outerHTML: anchor.outerHTML || '',
         };
       });
 
diff --git a/lighthouse-core/gather/gatherers/accessibility.js b/lighthouse-core/gather/gatherers/accessibility.js
index 800a3d873..8ec011298 100644
--- a/lighthouse-core/gather/gatherers/accessibility.js
+++ b/lighthouse-core/gather/gatherers/accessibility.js
@@ -5,11 +5,12 @@
  */
 'use strict';
 
-/* global window, document, Node */
+/* global window, document, Node, getOuterHTMLSnippet */
 
 const Gatherer = require('./gatherer');
 const fs = require('fs');
 const axeLibSource = fs.readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
+const pageFunctions = require('../../lib/page-functions');
 
 /**
  * This is run in the page, not Lighthouse itself.
@@ -44,6 +45,7 @@ function runA11yChecks() {
     // @ts-ignore
     axeResult.violations.forEach(v => v.nodes.forEach(node => {
       node.path = getNodePath(node.element);
+      // @ts-ignore - getOuterHTMLSnippet put into scope via stringification
       node.snippet = getOuterHTMLSnippet(node.element);
       // avoid circular JSON concerns
       node.element = node.any = node.all = node.none = undefined;
@@ -84,18 +86,6 @@ function runA11yChecks() {
     path.reverse();
     return path.join(',');
   }
-
-  /**
-   * Gets the opening tag text of the given node.
-   * @param {Element} el
-   * @return {string}
-   */
-  function getOuterHTMLSnippet(el) {
-    const reOpeningTag = /^.*?>/;
-    const match = el.outerHTML.match(reOpeningTag);
-    // Should always be a snippet, so just fall back to '' to keep a string.
-    return match && match[0] || '';
-  }
 }
 
 class Accessibility extends Gatherer {
@@ -106,6 +96,7 @@ class Accessibility extends Gatherer {
   afterPass(passContext) {
     const driver = passContext.driver;
     const expression = `(function () {
+      ${pageFunctions.getOuterHTMLSnippet.toString()};
       ${axeLibSource};
       return (${runA11yChecks.toString()}());
     })()`;
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js b/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js
index 1a2cd0ce3..d2beb9473 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Gatherer = require('../gatherer');
-const DOMHelpers = require('../../../lib/dom-helpers.js');
+const pageFunctions = require('../../../lib/page-functions.js');
 
 class AnchorsWithNoRelNoopener extends Gatherer {
   /**
@@ -15,13 +15,15 @@ class AnchorsWithNoRelNoopener extends Gatherer {
    */
   afterPass(passContext) {
     const expression = `(function() {
-      ${DOMHelpers.getElementsInDocumentFnString}; // define function on page
+      ${pageFunctions.getOuterHTMLSnippet.toString()};
+      ${pageFunctions.getElementsInDocument.toString()}; // define function on page
       const selector = 'a[target="_blank"]:not([rel~="noopener"]):not([rel~="noreferrer"])';
       const elements = getElementsInDocument(selector);
       return elements.map(node => ({
         href: node.href,
         rel: node.getAttribute('rel'),
-        target: node.getAttribute('target')
+        target: node.getAttribute('target'),
+        outerHTML: getOuterHTMLSnippet(node),
       }));
     })()`;
 
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/domstats.js b/lighthouse-core/gather/gatherers/dobetterweb/domstats.js
index a524339ff..866e7b633 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/domstats.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/domstats.js
@@ -9,23 +9,12 @@
  * and total number of nodes used on the page.
  */
 
-/* global ShadowRoot */
+/* global ShadowRoot, getOuterHTMLSnippet */
 
 'use strict';
 
 const Gatherer = require('../gatherer');
-
-/**
- * Gets the opening tag text of the given node.
- * @param {Element} element
- * @return {?string}
- */
-/* istanbul ignore next */
-function getOuterHTMLSnippet(element) {
-  const reOpeningTag = /^.*?>/;
-  const match = element.outerHTML.match(reOpeningTag);
-  return match && match[0];
-}
+const pageFunctions = require('../../../lib/page-functions');
 
 /**
  * Constructs a pretty label from element's selectors. For example, given
@@ -151,7 +140,7 @@ class DOMStats extends Gatherer {
    */
   afterPass(passContext) {
     const expression = `(function() {
-      ${getOuterHTMLSnippet.toString()};
+      ${pageFunctions.getOuterHTMLSnippet.toString()};
       ${createSelectorsLabel.toString()};
       ${elementPathInDOM.toString()};
       return (${getDOMStats.toString()}(document.documentElement));
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js b/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js
index 20adfdca4..6b50a0e7b 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js
@@ -5,9 +5,10 @@
  */
 'use strict';
 
-/* global document ClipboardEvent */
+/* global document ClipboardEvent getOuterHTMLSnippet */
 
 const Gatherer = require('../gatherer');
+const pageFunctions = require('../../../lib/page-functions');
 
 // This is run in the page, not Lighthouse itself.
 /**
@@ -15,18 +16,6 @@ const Gatherer = require('../gatherer');
  */
 /* istanbul ignore next */
 function findPasswordInputsWithPreventedPaste() {
-  /**
-   * Gets the opening tag text of the given element.
-   * @param {Element} element
-   * @return {string}
-   */
-  function getOuterHTMLSnippet(element) {
-    const reOpeningTag = /^.*?>/;
-    const match = element.outerHTML.match(reOpeningTag);
-    // @ts-ignore We are confident match was found.
-    return match && match[0];
-  }
-
   return Array.from(document.querySelectorAll('input[type="password"]'))
     .filter(passwordInput =>
       !passwordInput.dispatchEvent(
@@ -34,6 +23,7 @@ function findPasswordInputsWithPreventedPaste() {
       )
     )
     .map(passwordInput => ({
+      // @ts-ignore - getOuterHTMLSnippet put into scope via stringification
       snippet: getOuterHTMLSnippet(passwordInput),
     }));
 }
@@ -44,9 +34,10 @@ class PasswordInputsWithPreventedPaste extends Gatherer {
    * @return {Promise<LH.Artifacts['PasswordInputsWithPreventedPaste']>}
    */
   afterPass(passContext) {
-    return passContext.driver.evaluateAsync(
-      `(${findPasswordInputsWithPreventedPaste.toString()}())`
-    );
+    return passContext.driver.evaluateAsync(`(() => {
+      ${pageFunctions.getOuterHTMLSnippet.toString()};
+      return (${findPasswordInputsWithPreventedPaste.toString()}());
+    })()`);
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/image-usage.js b/lighthouse-core/gather/gatherers/image-usage.js
index e0debb4d2..c3f89ccbf 100644
--- a/lighthouse-core/gather/gatherers/image-usage.js
+++ b/lighthouse-core/gather/gatherers/image-usage.js
@@ -10,7 +10,7 @@
 'use strict';
 
 const Gatherer = require('./gatherer');
-const DOMHelpers = require('../../lib/dom-helpers.js');
+const pageFunctions = require('../../lib/page-functions.js');
 const Driver = require('../driver.js'); // eslint-disable-line no-unused-vars
 
 /* global window, getElementsInDocument, Image */
@@ -161,7 +161,7 @@ class ImageUsage extends Gatherer {
     }, /** @type {Object<string, LH.Artifacts.SingleImageUsage['networkRecord']>} */ ({}));
 
     const expression = `(function() {
-      ${DOMHelpers.getElementsInDocumentFnString}; // define function on page
+      ${pageFunctions.getElementsInDocument.toString()}; // define function on page
       return (${collectImageElementInfo.toString()})();
     })()`;
 
diff --git a/lighthouse-core/gather/gatherers/seo/crawlable-links.js b/lighthouse-core/gather/gatherers/seo/crawlable-links.js
index ed495113e..12354fa4d 100644
--- a/lighthouse-core/gather/gatherers/seo/crawlable-links.js
+++ b/lighthouse-core/gather/gatherers/seo/crawlable-links.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Gatherer = require('../gatherer');
-const DOMHelpers = require('../../../lib/dom-helpers.js');
+const pageFunctions = require('../../../lib/page-functions.js');
 
 class CrawlableLinks extends Gatherer {
   /**
@@ -15,7 +15,7 @@ class CrawlableLinks extends Gatherer {
    */
   afterPass(passContext) {
     const expression = `(function() {
-      ${DOMHelpers.getElementsInDocumentFnString}; // define function on page
+      ${pageFunctions.getElementsInDocument.toString()}; // define function on page
       const selector = 'a[href]:not([rel~="nofollow"])';
       const elements = getElementsInDocument(selector);
       return elements
diff --git a/lighthouse-core/gather/gatherers/seo/embedded-content.js b/lighthouse-core/gather/gatherers/seo/embedded-content.js
index 3df7258fa..da8717126 100644
--- a/lighthouse-core/gather/gatherers/seo/embedded-content.js
+++ b/lighthouse-core/gather/gatherers/seo/embedded-content.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Gatherer = require('../gatherer');
-const DOMHelpers = require('../../../lib/dom-helpers.js');
+const pageFunctions = require('../../../lib/page-functions.js');
 
 class EmbeddedContent extends Gatherer {
   /**
@@ -15,7 +15,7 @@ class EmbeddedContent extends Gatherer {
    */
   afterPass(passContext) {
     const expression = `(function() {
-      ${DOMHelpers.getElementsInDocumentFnString}; // define function on page
+      ${pageFunctions.getElementsInDocument.toString()}; // define function on page
       const selector = 'object, embed, applet';
       const elements = getElementsInDocument(selector);
       return elements
diff --git a/lighthouse-core/index.js b/lighthouse-core/index.js
index 1d2ddb3b8..52c6b8392 100644
--- a/lighthouse-core/index.js
+++ b/lighthouse-core/index.js
@@ -10,6 +10,8 @@ const log = require('lighthouse-logger');
 const ChromeProtocol = require('./gather/connections/cri.js');
 const Config = require('./config/config');
 
+/** @typedef {import('./gather/connections/connection.js')} Connection */
+
 /*
  * The relationship between these root modules:
  *
@@ -18,27 +20,30 @@ const Config = require('./config/config');
  *   runner.js - marshalls the actions that must be taken (Gather / Audit)
  *               config file is used to determine which of these actions are needed
  *
- *   lighthouse-cli \
- *                   -- index.js  \
- *                                 ----- runner.js ----> [Gather / Audit]
- *           lighthouse-extension /
- *
+ *         lighthouse-cli \
+ *                         -- core/index.js ----> runner.js ----> [Gather / Audit]
+ *   lighthouse-extension /
  */
 
 /**
- * @param {string=} url
- * @param {LH.Flags=} flags
- * @param {LH.Config.Json=} configJSON
+ * Run Lighthouse.
+ * @param {string=} url The URL to test. Optional if running in auditMode.
+ * @param {LH.Flags=} flags Optional settings for the Lighthouse run. If present,
+ *   they will override any settings in the config.
+ * @param {LH.Config.Json=} configJSON Configuration for the Lighthouse run. If
+ *   not present, the default config is used.
+ * @param {Connection=} connection
  * @return {Promise<LH.RunnerResult|undefined>}
  */
-async function lighthouse(url, flags = {}, configJSON) {
+async function lighthouse(url, flags = {}, configJSON, connection) {
   // set logging preferences, assume quiet
   flags.logLevel = flags.logLevel || 'error';
   log.setLevel(flags.logLevel);
 
   // Use ConfigParser to generate a valid config file
   const config = new Config(configJSON, flags);
-  const connection = new ChromeProtocol(flags.port, flags.hostname);
+
+  connection = connection || new ChromeProtocol(flags.port, flags.hostname);
 
   // kick off a lighthouse run
   return Runner.run(connection, {url, config});
diff --git a/lighthouse-core/lib/dom-helpers.js b/lighthouse-core/lib/dom-helpers.js
deleted file mode 100644
index ef7c59c74..000000000
--- a/lighthouse-core/lib/dom-helpers.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license Copyright 2017 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 Helpers to be used with driver.evaluateAsync(). That is, these
- * methods are intended to be injected into and run in the context of the page.
- */
-
-/* global document */
-
-// TODO: move to page-functions.js
-
-/**
- * @param {string=} selector Optional simple CSS selector to filter nodes on.
- *     Combinators are not supported.
- * @return {Array<Element>}
- */
-/* istanbul ignore next */
-function getElementsInDocument(selector) {
-  /** @type {Array<Element>} */
-  const results = [];
-
-  /** @param {NodeListOf<Element>} nodes */
-  const _findAllElements = nodes => {
-    for (let i = 0, el; el = nodes[i]; ++i) {
-      if (!selector || el.matches(selector)) {
-        results.push(el);
-      }
-      // If the element has a shadow root, dig deeper.
-      if (el.shadowRoot) {
-        _findAllElements(el.shadowRoot.querySelectorAll('*'));
-      }
-    }
-  };
-  _findAllElements(document.querySelectorAll('*'));
-
-  return results;
-}
-
-module.exports = {
-  getElementsInDocumentFnString: getElementsInDocument.toString(),
-};
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 8480ee295..ee828a05e 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -6,7 +6,7 @@
 // @ts-nocheck
 'use strict';
 
-/* global window */
+/* global window document */
 
 /**
  * Helper functions that are passed by `toString()` by Driver to be evaluated in target page.
@@ -78,8 +78,49 @@ function checkTimeSinceLastLongTask() {
   });
 }
 
+/**
+ * @param {string=} selector Optional simple CSS selector to filter nodes on.
+ *     Combinators are not supported.
+ * @return {Array<Element>}
+ */
+/* istanbul ignore next */
+function getElementsInDocument(selector) {
+  /** @type {Array<Element>} */
+  const results = [];
+
+  /** @param {NodeListOf<Element>} nodes */
+  const _findAllElements = nodes => {
+    for (let i = 0, el; el = nodes[i]; ++i) {
+      if (!selector || el.matches(selector)) {
+        results.push(el);
+      }
+      // If the element has a shadow root, dig deeper.
+      if (el.shadowRoot) {
+        _findAllElements(el.shadowRoot.querySelectorAll('*'));
+      }
+    }
+  };
+  _findAllElements(document.querySelectorAll('*'));
+
+  return results;
+}
+
+/**
+ * Gets the opening tag text of the given node.
+ * @param {Element} element
+ * @return {string}
+ */
+/* istanbul ignore next */
+function getOuterHTMLSnippet(element) {
+  const reOpeningTag = /^.*?>/;
+  const match = element.outerHTML.match(reOpeningTag);
+  return match && match[0] || '';
+}
+
 module.exports = {
   wrapRuntimeEvalErrorInBrowser,
   registerPerformanceObserverInPage,
   checkTimeSinceLastLongTask,
+  getElementsInDocument,
+  getOuterHTMLSnippet,
 };
diff --git a/lighthouse-extension/app/src/lighthouse-background.js b/lighthouse-extension/app/src/lighthouse-background.js
index 8fde330f6..7e010aa82 100644
--- a/lighthouse-extension/app/src/lighthouse-background.js
+++ b/lighthouse-extension/app/src/lighthouse-background.js
@@ -5,8 +5,8 @@
  */
 'use strict';
 
+const lighthouse = require('../../../lighthouse-core/index');
 const RawProtocol = require('../../../lighthouse-core/gather/connections/raw');
-const Runner = require('../../../lighthouse-core/runner');
 const Config = require('../../../lighthouse-core/config/config');
 const defaultConfig = require('../../../lighthouse-core/config/default-config.js');
 const i18n = require('../../../lighthouse-core/lib/i18n');
@@ -15,32 +15,18 @@ const log = require('lighthouse-logger');
 /** @typedef {import('../../../lighthouse-core/gather/connections/connection.js')} Connection */
 
 /**
- * @param {Connection} connection
- * @param {string} url
- * @param {LH.Flags} flags Lighthouse flags.
- * @param {Array<string>} categoryIDs Name values of categories to include.
- * @param {(url?: string) => void} updateBadgeFn
- * @return {Promise<LH.RunnerResult|void>}
+ * Return a version of the default config, filtered to only run the specified
+ * categories.
+ * @param {Array<string>} categoryIDs
+ * @return {LH.Config.Json}
  */
-function runLighthouseForConnection(connection, url, flags, categoryIDs, updateBadgeFn = _ => {}) {
-  const config = new Config({
+function getDefaultConfigForCategories(categoryIDs) {
+  return {
     extends: 'lighthouse:default',
     settings: {
       onlyCategories: categoryIDs,
     },
-  }, flags);
-
-  updateBadgeFn(url);
-
-  return Runner.run(connection, {url, config}) // Run Lighthouse.
-    .then(result => {
-      updateBadgeFn();
-      return result;
-    })
-    .catch(err => {
-      updateBadgeFn();
-      throw err;
-    });
+  };
 }
 
 /**
@@ -52,9 +38,11 @@ function runLighthouseForConnection(connection, url, flags, categoryIDs, updateB
  */
 function runLighthouseInWorker(port, url, flags, categoryIDs) {
   // Default to 'info' logging level.
-  log.setLevel('info');
+  flags.logLevel = flags.logLevel || 'info';
+  const config = getDefaultConfigForCategories(categoryIDs);
   const connection = new RawProtocol(port);
-  return runLighthouseForConnection(connection, url, flags, categoryIDs);
+
+  return lighthouse(url, flags, config, connection);
 }
 
 /**
@@ -75,7 +63,7 @@ function listenForStatus(listenCallback) {
 if (typeof module !== 'undefined' && module.exports) {
   // export for lighthouse-ext-background to require (via browserify).
   module.exports = {
-    runLighthouseForConnection,
+    getDefaultConfigForCategories,
     runLighthouseInWorker,
     getDefaultCategories,
     listenForStatus,
diff --git a/lighthouse-extension/app/src/lighthouse-ext-background.js b/lighthouse-extension/app/src/lighthouse-ext-background.js
index 2a011dac8..4b8b46146 100644
--- a/lighthouse-extension/app/src/lighthouse-ext-background.js
+++ b/lighthouse-extension/app/src/lighthouse-ext-background.js
@@ -5,6 +5,7 @@
  */
 'use strict';
 
+const lighthouse = require('../../../lighthouse-core/index');
 const background = require('./lighthouse-background');
 
 const ExtensionProtocol = require('../../../lighthouse-core/gather/connections/extension');
@@ -53,13 +54,21 @@ function updateBadgeUI(optUrl) {
  */
 async function runLighthouseInExtension(flags, categoryIDs) {
   // Default to 'info' logging level.
-  log.setLevel('info');
-  const connection = new ExtensionProtocol();
+  flags.logLevel = flags.logLevel || 'info';
   flags.output = 'html';
 
+  const connection = new ExtensionProtocol();
   const url = await connection.getCurrentTabURL();
-  const runnerResult = await background.runLighthouseForConnection(connection, url, flags,
-    categoryIDs, updateBadgeUI);
+  const config = background.getDefaultConfigForCategories(categoryIDs);
+
+  updateBadgeUI(url);
+  let runnerResult;
+  try {
+    runnerResult = await lighthouse(url, flags, config, connection);
+  } finally {
+    updateBadgeUI();
+  }
+
   if (!runnerResult) {
     // For now, should always be a runnerResult as the extension can't do `gatherMode`
     throw new Error('no runnerResult generated by Lighthouse');
@@ -81,8 +90,9 @@ async function runLighthouseInExtension(flags, categoryIDs) {
  * @return {Promise<string|Array<string>|void>}
  */
 async function runLighthouseAsInCLI(connection, url, flags, categoryIDs, {logAssets}) {
-  log.setLevel('info');
-  const results = await background.runLighthouseForConnection(connection, url, flags, categoryIDs);
+  flags.logLevel = flags.logLevel || 'info';
+  const config = background.getDefaultConfigForCategories(categoryIDs);
+  const results = await lighthouse(url, flags, config, connection);
   if (!results) return;
 
   if (logAssets) {
diff --git a/lighthouse-extension/gulpfile.js b/lighthouse-extension/gulpfile.js
index 20ed862e3..3e71daf2e 100644
--- a/lighthouse-extension/gulpfile.js
+++ b/lighthouse-extension/gulpfile.js
@@ -131,6 +131,9 @@ gulp.task('browserify-lighthouse', () => {
       .ignore('rimraf')
       .ignore('pako/lib/zlib/inflate.js');
 
+      // Don't include the desktop protocol connection.
+      bundle.ignore(require.resolve('../lighthouse-core/gather/connections/cri.js'));
+
       // Prevent the DevTools background script from getting the stringified HTML.
       if (/lighthouse-background/.test(file.path)) {
         bundle.ignore(require.resolve('../lighthouse-core/report/html/html-report-assets.js'));
diff --git a/typings/artifacts.d.ts b/typings/artifacts.d.ts
index 7238139c9..9f3103892 100644
--- a/typings/artifacts.d.ts
+++ b/typings/artifacts.d.ts
@@ -43,7 +43,7 @@ declare global {
       /** The results of running the aXe accessibility tests on the page. */
       Accessibility: Artifacts.Accessibility;
       /** Information on all anchors in the page that aren't nofollow or noreferrer. */
-      AnchorsWithNoRelNoopener: {href: string; rel: string; target: string}[];
+      AnchorsWithNoRelNoopener: {href: string; rel: string; target: string, outerHTML: string}[];
       /** The value of the page's <html> manifest attribute, or null if not defined */
       AppCacheManifest: string | null;
       /** Array of all URLs cached in CacheStorage. */


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
index 3bdd03ce3..0cd47040d 100644
--- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
@@ -39,7 +39,7 @@ module.exports = [
       },
       'external-anchors-use-rel-noopener': {
         score: 0,
-        warnings: [/Unable to determine/],
+        warnings: [/Unable to determine.*<a target="_blank">/],
         details: {
           items: {
             length: 3,
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index ee1761c5e..38f785e80 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -2227,16 +2227,19 @@
   "AnchorsWithNoRelNoopener": [
     {
       "href": "https://www.google.com/",
+      "outerHTML": "<a href=\"https://www.google.com/\" target=\"blank\">Hello</a>",
       "rel": null,
       "target": "_blank"
     },
     {
       "href": "",
+      "outerHTML": "<a target=\"blank\">Hello</a>",
       "rel": null,
       "target": "_blank"
     },
     {
       "href": "https://www.google.com/",
+      "outerHTML": "<a rel=\"nofollow\" href=\"https://www.google.com/\" target=\"blank\">Hello</a>",
       "rel": "nofollow",
       "target": "_blank"
     },
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index b31e7baa2..f3378f94d 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2226,7 +2226,7 @@
       "scoreDisplayMode": "binary",
       "rawValue": false,
       "warnings": [
-        "Unable to determine the destination for anchor tag. If not used as a hyperlink, consider removing target=_blank."
+        "Unable to determine the destination for anchor (<a target=\"blank\">Hello</a>). If not used as a hyperlink, consider removing target=_blank."
       ],
       "details": {
         "type": "table",
@@ -2252,19 +2252,19 @@
             "href": "https://www.google.com/",
             "target": "_blank",
             "rel": "",
-            "url": "<a href=\"https://www.google.com/\" target=\"_blank\">"
+            "outerHTML": "<a href=\"https://www.google.com/\" target=\"blank\">Hello</a>"
           },
           {
             "href": "Unknown",
             "target": "_blank",
             "rel": "",
-            "url": "<a target=\"_blank\">"
+            "outerHTML": "<a target=\"blank\">Hello</a>"
           },
           {
             "href": "https://www.google.com/",
             "target": "_blank",
             "rel": "nofollow",
-            "url": "<a href=\"https://www.google.com/\" target=\"_blank\" rel=\"nofollow\">"
+            "outerHTML": "<a rel=\"nofollow\" href=\"https://www.google.com/\" target=\"blank\">Hello</a>"
           }
         ]
       }