Repository: GoogleChrome/lighthouse
Pull request #9283 (2019-06-25 22:10:28)

misc(build): create error-y LHR for the deploy
will aid in looking at some of @connorjclark's recent PRs.

paulirish: @connorjclark this one doesn't add any `runWarnings`.... it probably should. got a suggestion for what to add in there?
connorjclark: `"Something went wrong with recording the trace over your page load. Please run Lighthouse again. (NO_FCP)"`
connorjclark: a classic
patrickhulce: > got a suggestion for what to add in there?

We can just throw any string into the `LighthouseRunWarnings` artifact, right?
googlebot: So there's good news and bad news.

:thumbsup: The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so.  Everything is all good there.

:confused: The bad news is that it appears that one or more commits were authored or co-authored by someone other than the pull request submitter.  We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that here in the pull request.

*Note to project maintainer: This is a terminal state, meaning the `cla/google` commit status will not change from this state. It's up to you to confirm consent of all the commit author(s), set the `cla` label to `yes` (if enabled on your project), and then merge this pull request when appropriate.*

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2FGoogleChrome%2Flighthouse%2Fpull%2F9283) for more info**.

<!-- need_author_consent -->
paulirish: > We can just throw any string into the LighthouseRunWarnings artifact, right?

yup. just wanted something realistic. ;)
paulirish: > What do you think about checking in error artifacts?

I was thinking that (and i've done something like that locally for months) but calling `GatherRunner.initializeBaseArtifacts` gives me the same thing, without as much maintenance cost. 

Not sure if you were suggesting it, but also adding that reverse engineering how to structure artifacts to get these warnings also is not fun.

Patch:
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 2a857035e..f506b5f5c 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -22,6 +22,28 @@ const {requireAudits, mergeOptionsOfItems, resolveModule} = require('./config-he
 /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */
 /** @typedef {InstanceType<GathererConstructor>} Gatherer */
 
+/**
+ * Define with object literal so that tsc will require it to stay updated.
+ * @type {Record<keyof LH.BaseArtifacts, ''>}
+ */
+const BASE_ARTIFACT_BLANKS = {
+  fetchTime: '',
+  LighthouseRunWarnings: '',
+  TestedAsMobileDevice: '',
+  HostUserAgent: '',
+  NetworkUserAgent: '',
+  BenchmarkIndex: '',
+  WebAppManifest: '',
+  Stacks: '',
+  traces: '',
+  devtoolsLogs: '',
+  settings: '',
+  URL: '',
+  Timing: '',
+  PageLoadError: '',
+};
+const BASE_ARTIFACT_NAMES = Object.keys(BASE_ARTIFACT_BLANKS);
+
 /**
  * @param {Config['passes']} passes
  * @param {Config['audits']} audits
@@ -32,11 +54,14 @@ function assertValidPasses(passes, audits) {
   }
 
   const requiredGatherers = Config.getGatherersNeededByAudits(audits);
+  // Base artifacts are provided by GatherRunner, so start foundGatherers with them.
+  const foundGatherers = new Set(BASE_ARTIFACT_NAMES);
 
   // Log if we are running gathers that are not needed by the audits listed in the config
   passes.forEach(pass => {
     pass.gatherers.forEach(gathererDefn => {
       const gatherer = gathererDefn.instance;
+      foundGatherers.add(gatherer.name);
       const isGatherRequiredByAudits = requiredGatherers.has(gatherer.name);
       if (!isGatherRequiredByAudits) {
         const msg = `${gatherer.name} gatherer requested, however no audit requires it.`;
@@ -45,6 +70,17 @@ function assertValidPasses(passes, audits) {
     });
   });
 
+  // All required gatherers must be found in the config. Throw otherwise.
+  for (const auditDefn of audits || []) {
+    const auditMeta = auditDefn.implementation.meta;
+    for (const requiredArtifact of auditMeta.requiredArtifacts) {
+      if (!foundGatherers.has(requiredArtifact)) {
+        throw new Error(`${requiredArtifact} gatherer, required by audit ${auditMeta.id}, ` +
+            'was not found in config.');
+      }
+    }
+  }
+
   // Passes must have unique `passName`s. Throw otherwise.
   const usedNames = new Set();
   passes.forEach(pass => {
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 3e98ca23b..78b910a85 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -12,6 +12,7 @@ const LHError = require('../lib/lh-error.js');
 const URL = require('../lib/url-shim.js');
 const NetworkRecorder = require('../lib/network-recorder.js');
 const constants = require('../config/constants.js');
+const i18n = require('../lib/i18n/i18n.js');
 
 /** @typedef {import('../gather/driver.js')} Driver */
 
@@ -646,7 +647,10 @@ class GatherRunner {
     // In case of load error, save log and trace with an error prefix, return no artifacts for this pass.
     const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData, possibleNavError);
     if (pageLoadError) {
-      log.error('GatherRunner', pageLoadError.friendlyMessage, passContext.url);
+      const localizedMessage = i18n.getFormatted(pageLoadError.friendlyMessage,
+          passContext.settings.locale);
+      log.error('GatherRunner', localizedMessage, passContext.url);
+
       passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
       GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData,
           `pageLoadError-${passConfig.passName}`);
diff --git a/lighthouse-core/lib/file-namer.js b/lighthouse-core/lib/file-namer.js
index e742b857b..f2ecd4e6e 100644
--- a/lighthouse-core/lib/file-namer.js
+++ b/lighthouse-core/lib/file-namer.js
@@ -33,7 +33,7 @@ function getFilenamePrefix(lhr) {
 
   const filenamePrefix = `${hostname}_${dateStr}_${timeStr}`;
   // replace characters that are unfriendly to filenames
-  return filenamePrefix.replace(/[/?<>\\:*|":]/g, '-');
+  return filenamePrefix.replace(/[/?<>\\:*|"]/g, '-');
 }
 
 // don't attempt to export in the browser.
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 95ed40853..b0dd8ba42 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1671,10 +1671,18 @@
     "message": "DNS servers could not resolve the provided domain.",
     "description": "Error message explaining that the requested page could not be resolved by the DNS server."
   },
+  "lighthouse-core/lib/lh-error.js | erroredRequiredArtifact": {
+    "message": "Required {artifactName} gatherer encountered an error: {errorMessage}",
+    "description": "Error message explaning that there was an error while trying to collect a resource that was required for testing. \"artifactName\" will be replaced with the name of the resource that wasn't collected; \"errorMessage\" will be replaced with a string description of the error that occurred."
+  },
   "lighthouse-core/lib/lh-error.js | internalChromeError": {
     "message": "An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.",
     "description": "Error message explaining that Chrome has encountered an error during the Lighthouse run, and that Chrome should be restarted."
   },
+  "lighthouse-core/lib/lh-error.js | missingRequiredArtifact": {
+    "message": "Required {artifactName} gatherer did not run.",
+    "description": "Error message explaning that a resource that was required for testing was never collected. \"artifactName\" will be replaced with the name of the resource that wasn't collected."
+  },
   "lighthouse-core/lib/lh-error.js | pageLoadFailed": {
     "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.",
     "description": "Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability."
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 1fd5d99ab..d3da92e48 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -183,6 +183,8 @@ const _ICUMsgNotFoundMsg = 'ICU message not found in destination locale';
  */
 function _formatIcuMessage(locale, icuMessageId, fallbackMessage, values) {
   const localeMessages = LOCALES[locale];
+  if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
+
   const localeMessage = localeMessages[icuMessageId] && localeMessages[icuMessageId].message;
   // fallback to the original english message if we couldn't find a message in the specified locale
   // better to have an english message than no message at all, in some number cases it won't even matter
@@ -220,13 +222,16 @@ function _formatPathAsString(pathInLHR) {
  * @return {LH.I18NRendererStrings}
  */
 function getRendererFormattedStrings(locale) {
-  const icuMessageIds = Object.keys(LOCALES[locale]).filter(f => f.includes('core/report/html/'));
+  const localeMessages = LOCALES[locale];
+  if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
+
+  const icuMessageIds = Object.keys(localeMessages).filter(f => f.includes('core/report/html/'));
   /** @type {LH.I18NRendererStrings} */
   const strings = {};
   for (const icuMessageId of icuMessageIds) {
     const [filename, varName] = icuMessageId.split(' | ');
     if (!filename.endsWith('util.js')) throw new Error(`Unexpected message: ${icuMessageId}`);
-    strings[varName] = LOCALES[locale][icuMessageId].message;
+    strings[varName] = localeMessages[icuMessageId].message;
   }
 
   return strings;
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index cf2c5be0d..63804c1ac 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -39,6 +39,10 @@ const UIStrings = {
   pageLoadFailedHung: 'Lighthouse was unable to reliably load the URL you requested because the page stopped responding.',
   /** Error message explaining that Lighthouse timed out while waiting for the initial connection to the Chrome Devtools protocol. */
   criTimeout: 'Timeout waiting for initial Debugger Protocol connection.',
+  /** Error message explaning that a resource that was required for testing was never collected. "artifactName" will be replaced with the name of the resource that wasn't collected. */
+  missingRequiredArtifact: 'Required {artifactName} gatherer did not run.',
+  /** Error message explaning that there was an error while trying to collect a resource that was required for testing. "artifactName" will be replaced with the name of the resource that wasn't collected; "errorMessage" will be replaced with a string description of the error that occurred. */
+  erroredRequiredArtifact: 'Required {artifactName} gatherer encountered an error: {errorMessage}',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -245,6 +249,24 @@ const ERRORS = {
     lhrRuntimeError: true,
   },
 
+  /**
+   * Error internal to Runner used when an artifact required for an audit is missing.
+   * Requires an additional `artifactName` field for translation.
+  */
+  MISSING_REQUIRED_ARTIFACT: {
+    code: 'MISSING_REQUIRED_ARTIFACT',
+    message: UIStrings.missingRequiredArtifact,
+  },
+
+  /**
+   * Error internal to Runner used when an artifact required for an audit was an error.
+   * Requires additional `artifactName` and `errorMessage` fields for translation.
+  */
+  ERRORED_REQUIRED_ARTIFACT: {
+    code: 'ERRORED_REQUIRED_ARTIFACT',
+    message: UIStrings.erroredRequiredArtifact,
+  },
+
   // Hey! When adding a new error type, update lighthouse-result.proto too.
 };
 
diff --git a/lighthouse-core/report/html/renderer/crc-details-renderer.js b/lighthouse-core/report/html/renderer/crc-details-renderer.js
index 150064d47..0c53f0865 100644
--- a/lighthouse-core/report/html/renderer/crc-details-renderer.js
+++ b/lighthouse-core/report/html/renderer/crc-details-renderer.js
@@ -84,9 +84,10 @@ class CriticalRequestChainRenderer {
    * @param {DOM} dom
    * @param {DocumentFragment} tmpl
    * @param {CRCSegment} segment
+   * @param {DetailsRenderer} detailsRenderer
    * @return {Node}
    */
-  static createChainNode(dom, tmpl, segment) {
+  static createChainNode(dom, tmpl, segment, detailsRenderer) {
     const chainsEl = dom.cloneTemplate('#tmpl-lh-crc__chains', tmpl);
 
     // Hovering over request shows full URL.
@@ -120,10 +121,10 @@ class CriticalRequestChainRenderer {
     }
 
     // Fill in url, host, and request size information.
-    const {file, hostname} = Util.parseURL(segment.node.request.url);
+    const url = segment.node.request.url;
+    const linkEl = detailsRenderer.renderTextURL(url);
     const treevalEl = dom.find('.crc-node__tree-value', chainsEl);
-    dom.find('.crc-node__tree-file', treevalEl).textContent = `${file}`;
-    dom.find('.crc-node__tree-hostname', treevalEl).textContent = hostname ? `(${hostname})` : '';
+    treevalEl.appendChild(linkEl);
 
     if (!segment.hasChildren) {
       const {startTime, endTime, transferSize} = segment.node.request;
@@ -146,14 +147,15 @@ class CriticalRequestChainRenderer {
    * @param {CRCSegment} segment
    * @param {Element} elem Parent element.
    * @param {LH.Audit.Details.CriticalRequestChain} details
+   * @param {DetailsRenderer} detailsRenderer
    */
-  static buildTree(dom, tmpl, segment, elem, details) {
-    elem.appendChild(CriticalRequestChainRenderer.createChainNode(dom, tmpl, segment));
+  static buildTree(dom, tmpl, segment, elem, details, detailsRenderer) {
+    elem.appendChild(CRCRenderer.createChainNode(dom, tmpl, segment, detailsRenderer));
     if (segment.node.children) {
       for (const key of Object.keys(segment.node.children)) {
-        const childSegment = CriticalRequestChainRenderer.createSegment(segment.node.children, key,
+        const childSegment = CRCRenderer.createSegment(segment.node.children, key,
           segment.startTime, segment.transferSize, segment.treeMarkers, segment.isLastChild);
-        CriticalRequestChainRenderer.buildTree(dom, tmpl, childSegment, elem, details);
+        CRCRenderer.buildTree(dom, tmpl, childSegment, elem, details, detailsRenderer);
       }
     }
   }
@@ -162,9 +164,10 @@ class CriticalRequestChainRenderer {
    * @param {DOM} dom
    * @param {ParentNode} templateContext
    * @param {LH.Audit.Details.CriticalRequestChain} details
+   * @param {DetailsRenderer} detailsRenderer
    * @return {Element}
    */
-  static render(dom, templateContext, details) {
+  static render(dom, templateContext, details, detailsRenderer) {
     const tmpl = dom.cloneTemplate('#tmpl-lh-crc', templateContext);
     const containerEl = dom.find('.lh-crc', tmpl);
 
@@ -176,17 +179,19 @@ class CriticalRequestChainRenderer {
         Util.formatMilliseconds(details.longestChain.duration);
 
     // Construct visual tree.
-    const root = CriticalRequestChainRenderer.initTree(details.chains);
+    const root = CRCRenderer.initTree(details.chains);
     for (const key of Object.keys(root.tree)) {
-      const segment = CriticalRequestChainRenderer.createSegment(root.tree, key,
-          root.startTime, root.transferSize);
-      CriticalRequestChainRenderer.buildTree(dom, tmpl, segment, containerEl, details);
+      const segment = CRCRenderer.createSegment(root.tree, key, root.startTime, root.transferSize);
+      CRCRenderer.buildTree(dom, tmpl, segment, containerEl, details, detailsRenderer);
     }
 
     return dom.find('.lh-crc-container', tmpl);
   }
 }
 
+// Alias b/c the name is really long.
+const CRCRenderer = CriticalRequestChainRenderer;
+
 // Allow Node require()'ing.
 if (typeof module !== 'undefined' && module.exports) {
   module.exports = CriticalRequestChainRenderer;
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 4e26b2594..84a5d49f6 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -53,7 +53,7 @@ class DetailsRenderer {
       case 'table':
         return this._renderTable(details);
       case 'criticalrequestchain':
-        return CriticalRequestChainRenderer.render(this._dom, this._templateContext, details);
+        return CriticalRequestChainRenderer.render(this._dom, this._templateContext, details, this);
       case 'opportunity':
         return this._renderTable(details);
 
@@ -97,7 +97,7 @@ class DetailsRenderer {
    * @param {string} text
    * @return {HTMLElement}
    */
-  _renderTextURL(text) {
+  renderTextURL(text) {
     const url = text;
 
     let displayedPath;
@@ -113,7 +113,7 @@ class DetailsRenderer {
     }
 
     const element = this._dom.createElement('div', 'lh-text__url');
-    element.appendChild(this._renderText(displayedPath));
+    element.appendChild(this._renderLink({text: displayedPath, url}));
 
     if (displayedHost) {
       const hostElem = this._renderText(displayedHost);
@@ -130,7 +130,7 @@ class DetailsRenderer {
   }
 
   /**
-   * @param {LH.Audit.Details.LinkValue} details
+   * @param {{text: string, url: string}} details
    * @return {Element}
    */
   _renderLink(details) {
@@ -215,7 +215,7 @@ class DetailsRenderer {
           return this.renderNode(value);
         }
         case 'url': {
-          return this._renderTextURL(value.value);
+          return this.renderTextURL(value.value);
         }
         default: {
           throw new Error(`Unknown valueType: ${value.type}`);
@@ -260,7 +260,7 @@ class DetailsRenderer {
       case 'url': {
         const strValue = String(value);
         if (URL_PREFIXES.some(prefix => strValue.startsWith(prefix))) {
-          return this._renderTextURL(strValue);
+          return this.renderTextURL(strValue);
         } else {
           // Fall back to <pre> rendering if not actually a URL.
           return this._renderCode(strValue);
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 3eb813544..be06994c3 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -97,7 +97,6 @@
   --header-line-height: 24px;
   --highlighter-background-color: var(--report-text-color);
   --icon-square-size: calc(var(--score-icon-size) * 0.88);
-  --metric-description-padding: 0 0 2px calc(var(--score-icon-margin-left) + var(--score-icon-size) + var(--score-icon-margin-right));
   --metric-toggle-lines-fill: #7F7F7F;
   --metrics-toggle-background-color: var(--color-gray-200);
   --plugin-badge-background-color: var(--color-white);
@@ -434,7 +433,8 @@
 .lh-audit--fail .lh-audit__display-text {
   color: var(--color-fail-secondary);
 }
-.lh-audit--fail .lh-audit__score-icon {
+.lh-audit--fail .lh-audit__score-icon,
+.lh-audit--error .lh-audit__score-icon {
   border-left: calc(var(--score-icon-size) / 2) solid transparent;
   border-right: calc(var(--score-icon-size) / 2) solid transparent;
   border-bottom: var(--score-icon-size) solid var(--color-fail);
@@ -450,10 +450,6 @@
   background: var(--color-gray-400);
 }
 
-.lh-audit--error .lh-audit__score-icon {
-  display: none;
-}
-
 .lh-audit--informative .lh-audit__display-text {
   color: var(--color-gray-600);
 }
@@ -576,10 +572,9 @@
 }
 
 .lh-metric__innerwrap {
-  display: flex;
+  display: grid;
+  grid-template-columns: var(--audit-description-padding-left) 10fr 3fr;
   align-items: center;
-  flex-wrap: wrap;
-  justify-content: space-between;
   padding: 10px 0;
 }
 
@@ -599,13 +594,15 @@
 
 .lh-metric__description {
   display: none;
+  grid-column-start: 2;
+  grid-column-end: 3;
   color: var(--report-text-color-secondary);
-  padding: var(--metric-description-padding);
 }
 
 .lh-metric__value {
   white-space: nowrap; /* No wrapping between metric value and the icon */
   font-weight: 500;
+  justify-self: end;
 }
 
 /* No-JS toggle switch */
@@ -700,7 +697,8 @@
 .lh-metric--fail .lh-metric__value {
   color: var(--color-fail-secondary);
 }
-.lh-metric--fail .lh-metric__innerwrap::before {
+.lh-metric--fail .lh-metric__innerwrap::before,
+.lh-metric--error .lh-metric__innerwrap::before {
   border-left: calc(var(--score-icon-size) / 2) solid transparent;
   border-right: calc(var(--score-icon-size) / 2) solid transparent;
   border-bottom: var(--score-icon-size) solid var(--color-fail);
@@ -711,11 +709,6 @@
   color: var(--color-fail-secondary);
 }
 
-/* Hide icon if there was an error */
-.lh-metric--error .lh-metric__innerwrap::before {
-  display: none;
-}
-
 /* Perf load opportunity */
 
 .lh-load-opportunity__cols {
@@ -1372,12 +1365,8 @@
   width: 12%;
 }
 
-.lh-text__url:hover {
-  text-decoration: underline dotted #999;
-  text-decoration-skip-ink: auto;
-}
 
-.lh-text__url > .lh-text, .lh-text__url-host {
+.lh-text__url-host {
   display: inline;
 }
 
@@ -1393,6 +1382,15 @@
   object-fit: contain;
 }
 
+.lh-text__url > a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.lh-text__url > a:hover {
+  text-decoration: underline dotted #999;
+}
+
 /* Chevron
    https://codepen.io/paulirish/pen/LmzEmK
  */
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 92f633d0a..7bb51410b 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -741,12 +741,12 @@ limitations under the License.
       .lh-crc .crc-node__tree-value {
         margin-left: 10px;
       }
+      .lh-crc .crc-node__tree-value div {
+        display: inline;
+      }
       .lh-crc .crc-node__chain-duration {
         font-weight: 700;
       }
-      .lh-crc .crc-node__tree-hostname {
-        color: #595959;
-      }
       .lh-crc .crc-initial-nav {
         color: #595959;
         font-style: italic;
@@ -769,8 +769,6 @@ limitations under the License.
 
           </span>
           <span class="crc-node__tree-value">
-            <span class="crc-node__tree-file"><!-- fill me: node.request.url.file --></span>
-            <span class="crc-node__tree-hostname">(<!-- fill me: node.request.url.host -->)</span>
 
           </span>
         </div>
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index a798eda2e..689ed6799 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -289,14 +289,16 @@ class Runner {
       for (const artifactName of audit.meta.requiredArtifacts) {
         const noArtifact = artifacts[artifactName] === undefined;
 
-        // If trace required, check that DEFAULT_PASS trace exists.
-        // TODO: need pass-specific check of networkRecords and traces.
-        const noTrace = artifactName === 'traces' && !artifacts.traces[Audit.DEFAULT_PASS];
+        // If trace/devtoolsLog required, check that DEFAULT_PASS trace/devtoolsLog exists.
+        // NOTE: for now, not a pass-specific check of traces or devtoolsLogs.
+        const noRequiredTrace = artifactName === 'traces' && !artifacts.traces[Audit.DEFAULT_PASS];
+        const noRequiredDevtoolsLog = artifactName === 'devtoolsLogs' &&
+            !artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
 
-        if (noArtifact || noTrace) {
+        if (noArtifact || noRequiredTrace || noRequiredDevtoolsLog) {
           log.warn('Runner',
               `${artifactName} gatherer, required by audit ${audit.meta.id}, did not run.`);
-          throw new Error(`Required ${artifactName} gatherer did not run.`);
+          throw new LHError(LHError.errors.MISSING_REQUIRED_ARTIFACT, {artifactName});
         }
 
         // If artifact was an error, output error result on behalf of audit.
@@ -314,8 +316,8 @@ class Runner {
             ` encountered an error: ${artifactError.message}`);
 
           // Create a friendlier display error and mark it as expected to avoid duplicates in Sentry
-          const error = new Error(
-              `Required ${artifactName} gatherer encountered an error: ${artifactError.message}`);
+          const error = new LHError(LHError.errors.ERRORED_REQUIRED_ARTIFACT,
+              {artifactName, errorMessage: artifactError.message});
           // @ts-ignore Non-standard property added to Error
           error.expected = true;
           throw error;
@@ -340,7 +342,10 @@ class Runner {
       const product = await audit.audit(requiredArtifacts, auditContext);
       auditResult = Audit.generateAuditResult(audit, product);
     } catch (err) {
-      log.warn(audit.meta.id, `Caught exception: ${err.message}`);
+      // Log error if it hasn't already been logged above.
+      if (err.code !== 'MISSING_REQUIRED_ARTIFACT' && err.code !== 'ERRORED_REQUIRED_ARTIFACT') {
+        log.warn(audit.meta.id, `Caught exception: ${err.message}`);
+      }
 
       Sentry.captureException(err, {tags: {audit: audit.meta.id}, level: 'error'});
       // Errors become error audit result.
diff --git a/lighthouse-core/scripts/build-report-for-autodeployment.js b/lighthouse-core/scripts/build-report-for-autodeployment.js
index 5bfab5d56..7fdc9d06a 100644
--- a/lighthouse-core/scripts/build-report-for-autodeployment.js
+++ b/lighthouse-core/scripts/build-report-for-autodeployment.js
@@ -14,32 +14,33 @@ const mkdirp = require('mkdirp').sync;
 const swapLocale = require('../lib/i18n/swap-locale.js');
 
 const ReportGenerator = require('../../lighthouse-core/report/report-generator.js');
+const {defaultSettings} = require('../config/constants.js');
+const lighthouse = require('../index.js');
 const lhr = /** @type {LH.Result} */ (require('../../lighthouse-core/test/results/sample_v2.json'));
 
 const DIST = path.join(__dirname, `../../dist`);
 
-// Add a plugin to demo plugin rendering.
-lhr.categories['lighthouse-plugin-someplugin'] = {
-  id: 'lighthouse-plugin-someplugin',
-  title: 'Plugin',
-  score: 0.5,
-  auditRefs: [],
-};
-
 (async function() {
+  addPluginCategory(lhr);
+  const errorLhr = await generateErrorLHR();
+
   const filenameToLhr = {
     'english': lhr,
     'espanol': swapLocale(lhr, 'es').lhr,
     'arabic': swapLocale(lhr, 'ar').lhr,
+    'xl-accented': swapLocale(lhr, 'en-XL').lhr,
+    'error': errorLhr,
   };
 
-  mkdirp(DIST);
-
   // Generate and write reports
   Object.entries(filenameToLhr).forEach(([filename, lhr]) => {
     let html = ReportGenerator.generateReportHtml(lhr);
+    // TODO: PSI is another variant to consider
     for (const variant of ['', '-devtools']) {
       if (variant === '-devtools') {
+        // TODO: Make the DevTools Audits panel "emulation" more comprehensive
+        // - the parent widget/vbox container with overflow
+        // - a more constrained/realistic default size
         html = html.replace(`"lh-root lh-vars"`, `"lh-root lh-vars lh-devtools"`);
       }
       const filepath = `${DIST}/${filename}${variant}/index.html`;
@@ -49,3 +50,79 @@ lhr.categories['lighthouse-plugin-someplugin'] = {
     }
   });
 })();
+
+/**
+ * Add a plugin to demo plugin rendering.
+ * @param {LH.Result} lhr
+ */
+function addPluginCategory(lhr) {
+  lhr.categories['lighthouse-plugin-someplugin'] = {
+    id: 'lighthouse-plugin-someplugin',
+    title: 'Plugin',
+    score: 0.5,
+    auditRefs: [],
+  };
+}
+
+/**
+ * Generate an LHR with errors for the renderer to display.
+ * We'll write an "empty" artifacts file to disk, only to use it in auditMode.
+ * @return {Promise<LH.Result>}
+ */
+async function generateErrorLHR() {
+  /** @type {LH.BaseArtifacts} */
+  const artifacts = {
+    fetchTime: '2019-06-26T23:56:58.381Z',
+    LighthouseRunWarnings: [
+      `Something went wrong with recording the trace over your page load. Please run Lighthouse again. (NO_FCP)`, // eslint-disable-line max-len
+    ],
+    TestedAsMobileDevice: true,
+    HostUserAgent: 'Mozilla/5.0 ErrorUserAgent Chrome/66',
+    NetworkUserAgent: 'Mozilla/5.0 ErrorUserAgent Chrome/66',
+    BenchmarkIndex: 1000,
+    WebAppManifest: null,
+    Stacks: [],
+    settings: defaultSettings,
+    URL: {
+      requestedUrl: 'http://fakeurl.com',
+      finalUrl: 'http://fakeurl.com',
+    },
+    Timing: [],
+    PageLoadError: null,
+    devtoolsLogs: {},
+    traces: {},
+  };
+
+  // Save artifacts to disk then run `lighthouse -G` with them.
+  const TMP = `${DIST}/.tmp/`;
+  mkdirp(TMP);
+  fs.writeFileSync(`${TMP}/artifacts.json`, JSON.stringify(artifacts), 'utf-8');
+  const errorRunnerResult = await lighthouse(artifacts.URL.requestedUrl, {auditMode: TMP});
+
+  if (!errorRunnerResult) throw new Error('Failed to run lighthouse on empty artifacts');
+  const errorLhr = errorRunnerResult.lhr;
+
+  // Add audit warnings to font-display
+  errorLhr.audits['font-display'].warnings = [
+    'Lighthouse was unable to automatically check the font-display value for the following URL: https://secure-ds.serving-sys.com/resources/PROD/html5/105657/20190307/1074580285/43862346571980472/fonts/IBMPlexSans-Light-Latin1.woff.',
+    'Lighthouse was unable to automatically check the font-display value for the following URL: https://secure-ds.serving-sys.com/resources/PROD/html5/105657/20190307/1074580285/43862346571980472/fonts/IBMPlexSans-Bold-Latin1.woff.',
+  ];
+  // perf/offscreen-images - set as passing but with a warning
+  const offscreenImagesAudit = errorLhr.audits['offscreen-images'];
+  offscreenImagesAudit.warnings = [
+    'Invalid image sizing information: https://cdn.cnn.com/cnn/.e1mo/img/4.0/vr/vr_new_asset.png',
+  ];
+  offscreenImagesAudit.errorMessage = undefined;
+  offscreenImagesAudit.scoreDisplayMode = 'binary';
+  offscreenImagesAudit.score = 1;
+  // pwa-apple-touch-icon - set as passing but with a warning
+  const appleTouchIconAudit = errorLhr.audits['apple-touch-icon'];
+  appleTouchIconAudit.warnings = [
+    '`apple-touch-icon-precomposed` is out of date; `apple-touch-icon` is preferred.',
+  ];
+  appleTouchIconAudit.errorMessage = undefined;
+  appleTouchIconAudit.scoreDisplayMode = 'binary';
+  appleTouchIconAudit.score = 1;
+
+  return errorLhr;
+}
diff --git a/package.json b/package.json
index 556bc7f4c..8a7effe65 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,8 @@
     "mixed-content": "./lighthouse-cli/index.js --chrome-flags='--headless' --preset=mixed-content",
     "minify-latest-run": "./lighthouse-core/scripts/lantern/minify-trace.js ./latest-run/defaultPass.trace.json ./latest-run/defaultPass.trace.min.json && ./lighthouse-core/scripts/lantern/minify-devtoolslog.js ./latest-run/defaultPass.devtoolslog.json ./latest-run/defaultPass.devtoolslog.min.json",
     "compile-proto": "protoc --python_out=./ ./proto/lighthouse-result.proto && mv ./proto/*_pb2.py ./proto/scripts || (echo \"❌ Install protobuf ≥ 3.7.1 to compile the proto file.\" && false)",
-    "build-proto-roundtrip": "cd proto/scripts && PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=cpp PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION_VERSION=2 python json_roundtrip_via_proto.py"
+    "build-proto-roundtrip": "cd proto/scripts && PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=cpp PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION_VERSION=2 python json_roundtrip_via_proto.py",
+    "static-server": "node lighthouse-cli/test/fixtures/static-server.js"
   },
   "devDependencies": {
     "@firebase/app-types": "0.3.1",


Test patch:
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index ee4772668..c3700008f 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -30,7 +30,7 @@ describe('Config', () => {
     assert.notEqual(config, newConfig);
   });
 
-  it('doesn\'t change directly injected plugins', () => {
+  it('doesn\'t change directly injected gatherer implementations', () => {
     class MyGatherer extends Gatherer {}
     class MyAudit extends Audit {
       static get meta() {
@@ -45,7 +45,7 @@ describe('Config', () => {
       static audit() {}
     }
     const config = {
-      // Extend to default to double test our ability to handle plugins
+      // Extend default to double test our ability to handle injection.
       extends: 'lighthouse:default',
       settings: {onlyAudits: ['my-audit']},
       passes: [{
@@ -58,7 +58,7 @@ describe('Config', () => {
     assert.equal(MyAudit, newConfig.audits[0].implementation);
   });
 
-  it('doesn\'t change directly injected plugin instances', () => {
+  it('doesn\'t change directly injected gatherer instances', () => {
     class MyGatherer extends Gatherer {
       constructor(secretVal) {
         super();
@@ -120,6 +120,56 @@ describe('Config', () => {
     );
   });
 
+  it('throws when an audit requires an artifact with no gatherer supplying it', async () => {
+    class NeedsWhatYouCantGive extends Audit {
+      static get meta() {
+        return {
+          id: 'missing-artifact-audit',
+          title: 'none',
+          description: 'none',
+          requiredArtifacts: [
+            // Require fake artifact amidst base artifact and default artifacts.
+            'URL',
+            'ConsoleMessages',
+            'VRMLElements', // not a real gatherer
+            'ViewportDimensions',
+          ],
+        };
+      }
+
+      static audit() {}
+    }
+
+    expect(() => new Config({
+      extends: 'lighthouse:default',
+      audits: [NeedsWhatYouCantGive],
+    // eslint-disable-next-line max-len
+    })).toThrow('VRMLElements gatherer, required by audit missing-artifact-audit, was not found in config');
+  });
+
+  it('does not throw when an audit requires only base artifacts', () => {
+    class BaseArtifactsAudit extends Audit {
+      static get meta() {
+        return {
+          id: 'base-artifacts-audit',
+          title: 'base',
+          description: 'base',
+          requiredArtifacts: ['HostUserAgent', 'URL', 'Stacks', 'WebAppManifest'],
+        };
+      }
+
+      static audit() {}
+    }
+
+    const config = new Config({
+      extends: 'lighthouse:default',
+      audits: [BaseArtifactsAudit],
+    }, {onlyAudits: ['base-artifacts-audit']});
+
+    assert.strictEqual(config.audits.length, 1);
+    assert.strictEqual(config.audits[0].implementation.meta.id, 'base-artifacts-audit');
+  });
+
   it('throws for unknown gatherers', () => {
     const config = {
       passes: [{
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 1161af861..8d8077df0 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -424,7 +424,7 @@ describe('GatherRunner', function() {
     assert.equal(tests.calledCleanBrowserCaches, true);
   });
 
-  it('returns a pageLoadError and no artifacts with network errors', async () => {
+  it('returns a pageLoadError and no artifacts when there is a network error', async () => {
     const requestedUrl = 'https://example.com';
     // This page load error should be overriden by NO_DOCUMENT_REQUEST (for being
     // more specific) since requestedUrl does not match any network request in fixture.
@@ -434,31 +434,27 @@ describe('GatherRunner', function() {
       gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
     });
 
-    const passConfig = {
-      gatherers: [
-        {instance: new TestGatherer()},
-      ],
-    };
-
-    const settings = {};
-
-    const passContext = {
-      url: requestedUrl,
+    const config = new Config({
+      passes: [{
+        recordTrace: true,
+        passName: 'firstPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }],
+    });
+    const options = {
       driver,
-      passConfig,
-      settings,
-      LighthouseRunWarnings: [],
-      baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
+      requestedUrl,
+      settings: config.settings,
     };
 
-    const {artifacts, pageLoadError} = await GatherRunner.runPass(passContext);
-    expect(passContext.LighthouseRunWarnings).toHaveLength(1);
-    expect(pageLoadError).toBeInstanceOf(Error);
-    expect(pageLoadError.code).toEqual('NO_DOCUMENT_REQUEST');
-    expect(artifacts).toEqual({});
+    const artifacts = await GatherRunner.run(config.passes, options);
+    expect(artifacts.LighthouseRunWarnings).toHaveLength(1);
+    expect(artifacts.PageLoadError).toBeInstanceOf(Error);
+    expect(artifacts.PageLoadError.code).toEqual('NO_DOCUMENT_REQUEST');
+    expect(artifacts.TestGatherer).toBeUndefined();
   });
 
-  it('returns a pageLoadError and no artifacts with navigation errors', async () => {
+  it('returns a pageLoadError and no artifacts when there is a navigation error', async () => {
     const requestedUrl = 'https://example.com';
     // This time, NO_FCP should win because it's the only error left.
     const navigationError = new LHError(LHError.errors.NO_FCP);
@@ -470,28 +466,24 @@ describe('GatherRunner', function() {
       },
     });
 
-    const passConfig = {
-      gatherers: [
-        {instance: new TestGatherer()},
-      ],
-    };
-
-    const settings = {};
-
-    const passContext = {
-      url: requestedUrl,
+    const config = new Config({
+      passes: [{
+        recordTrace: true,
+        passName: 'firstPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }],
+    });
+    const options = {
       driver,
-      passConfig,
-      settings,
-      LighthouseRunWarnings: [],
-      baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
+      requestedUrl,
+      settings: config.settings,
     };
 
-    const {artifacts, pageLoadError} = await GatherRunner.runPass(passContext);
-    expect(passContext.LighthouseRunWarnings).toHaveLength(1);
-    expect(pageLoadError).toBeInstanceOf(Error);
-    expect(pageLoadError.code).toEqual('NO_FCP');
-    expect(artifacts).toEqual({});
+    const artifacts = await GatherRunner.run(config.passes, options);
+    expect(artifacts.LighthouseRunWarnings).toHaveLength(1);
+    expect(artifacts.PageLoadError).toBeInstanceOf(Error);
+    expect(artifacts.PageLoadError.code).toEqual('NO_FCP');
+    expect(artifacts.TestGatherer).toBeUndefined();
   });
 
   it('does not clear origin storage with flag --disable-storage-reset', () => {
diff --git a/lighthouse-core/test/lib/i18n/i18n-test.js b/lighthouse-core/test/lib/i18n/i18n-test.js
index f5d07a32e..185c1df5e 100644
--- a/lighthouse-core/test/lib/i18n/i18n-test.js
+++ b/lighthouse-core/test/lib/i18n/i18n-test.js
@@ -61,6 +61,22 @@ describe('i18n', () => {
       expect(strings.passedAuditsGroupTitle).toEqual('[Þåššéð åûðîţš one two]');
       expect(strings.snippetCollapseButtonLabel).toEqual('[Çöļļåþšé šñîþþéţ one two]');
     });
+
+    it('throws an error for invalid locales', () => {
+      expect(_ => i18n.getRendererFormattedStrings('not-a-locale'))
+        .toThrow(`Unsupported locale 'not-a-locale'`);
+    });
+  });
+
+  describe('#getFormatted', () => {
+    it('throws an error for invalid locales', () => {
+      // Populate a string to try to localize to a bad locale.
+      const UIStrings = {testMessage: 'testy test'};
+      const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+      expect(_ => i18n.getFormatted(str_(UIStrings.testMessage), 'still-not-a-locale'))
+        .toThrow(`Unsupported locale 'still-not-a-locale'`);
+    });
   });
 
   describe('#lookupLocale', () => {
diff --git a/lighthouse-core/test/report/html/renderer/crc-details-renderer-test.js b/lighthouse-core/test/report/html/renderer/crc-details-renderer-test.js
index 79de69ef5..f5d8ca14c 100644
--- a/lighthouse-core/test/report/html/renderer/crc-details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/crc-details-renderer-test.js
@@ -13,6 +13,7 @@ const jsdom = require('jsdom');
 const URL = require('../../../../lib/url-shim.js');
 const Util = require('../../../../report/html/renderer/util.js');
 const DOM = require('../../../../report/html/renderer/dom.js');
+const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
 const CriticalRequestChainRenderer =
     require('../../../../report/html/renderer/crc-details-renderer.js');
 
@@ -75,12 +76,14 @@ const DETAILS = {
 
 describe('DetailsRenderer', () => {
   let dom;
+  let detailsRenderer;
 
   beforeAll(() => {
     global.URL = URL;
     global.Util = Util;
     const {document} = new jsdom.JSDOM(TEMPLATE_FILE).window;
     dom = new DOM(document);
+    detailsRenderer = new DetailsRenderer(dom);
   });
 
   afterAll(() => {
@@ -89,18 +92,25 @@ describe('DetailsRenderer', () => {
   });
 
   it('renders tree structure', () => {
-    const el = CriticalRequestChainRenderer.render(dom, dom.document(), DETAILS);
+    const el = CriticalRequestChainRenderer.render(dom, dom.document(), DETAILS, detailsRenderer);
     const chains = el.querySelectorAll('.crc-node');
 
     // Main request
     assert.equal(chains.length, 4, 'generates correct number of chain nodes');
-    assert.equal(chains[0].querySelector('.crc-node__tree-hostname').textContent, '(example.com)');
+    assert.ok(!chains[0].querySelector('.lh-text__url-host'), 'should be no origin for root url');
+    assert.equal(chains[0].querySelector('.lh-text__url a').textContent, 'https://example.com');
+    assert.equal(chains[0].querySelector('.lh-text__url a').href, 'https://example.com/');
+    assert.equal(chains[0].querySelector('.lh-text__url a').rel, 'noopener');
+    assert.equal(chains[0].querySelector('.lh-text__url a').target, '_blank');
 
     // Children
     assert.ok(chains[1].querySelector('.crc-node__tree-marker .vert-right'));
     assert.equal(chains[1].querySelectorAll('.crc-node__tree-marker .right').length, 2);
-    assert.equal(chains[1].querySelector('.crc-node__tree-file').textContent, '/b.js');
-    assert.equal(chains[1].querySelector('.crc-node__tree-hostname').textContent, '(example.com)');
+    assert.equal(chains[1].querySelector('.lh-text__url a').textContent, '/b.js');
+    assert.equal(chains[1].querySelector('.lh-text__url a').href, 'https://example.com/b.js');
+    assert.equal(chains[1].querySelector('.lh-text__url a').rel, 'noopener');
+    assert.equal(chains[1].querySelector('.lh-text__url a').target, '_blank');
+    assert.equal(chains[1].querySelector('.lh-text__url-host').textContent, '(example.com)');
     const durationNodes = chains[1].querySelectorAll('.crc-node__chain-duration');
     assert.equal(durationNodes[0].textContent, ' - 5,000\xa0ms, ');
     // Note: actual transferSize is 2000 bytes but formatter formats to KBs.
diff --git a/lighthouse-core/test/report/html/renderer/details-renderer-test.js b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
index 261172a73..ba7cf34c7 100644
--- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
@@ -405,7 +405,10 @@ describe('DetailsRenderer', () => {
       assert.equal(urlEl.localName, 'div');
       assert.equal(urlEl.title, urlText);
       assert.equal(urlEl.dataset.url, urlText);
-      assert.ok(urlEl.firstChild.classList.contains('lh-text'));
+      assert.equal(urlEl.firstChild.nodeName, 'A');
+      assert.equal(urlEl.firstChild.href, urlText);
+      assert.equal(urlEl.firstChild.rel, 'noopener');
+      assert.equal(urlEl.firstChild.target, '_blank');
       assert.equal(urlEl.textContent, displayUrlText);
     });
 
@@ -430,7 +433,7 @@ describe('DetailsRenderer', () => {
       assert.equal(urlEl.localName, 'div');
       assert.equal(urlEl.title, urlText);
       assert.equal(urlEl.dataset.url, urlText);
-      assert.ok(urlEl.firstChild.classList.contains('lh-text'));
+      assert.equal(urlEl.firstChild.nodeName, 'A');
       assert.equal(urlEl.textContent, displayUrlText);
     });
 
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 d706fc4f8..2beb09fec 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
@@ -185,7 +185,7 @@ describe('ReportUIFeatures', () => {
 
         function getUrlsInTable() {
           return dom
-            .findAll('#uses-webp-images .lh-details .lh-text__url .lh-text:first-child', container)
+            .findAll('#uses-webp-images .lh-details .lh-text__url a:first-child', container)
             .map(el => el.textContent);
         }
 
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index e94e8e6ee..bb9b38c6c 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -293,6 +293,24 @@ describe('Runner', () => {
       });
     });
 
+    it('outputs an error audit result when devtoolsLog required but not provided', async () => {
+      const config = new Config({
+        settings: {
+          auditMode: __dirname + '/fixtures/artifacts/empty-artifacts/',
+        },
+        audits: [
+          // requires devtoolsLogs[Audit.DEFAULT_PASS]
+          'is-on-https',
+        ],
+      });
+
+      const results = await Runner.run({}, {config});
+      const auditResult = results.lhr.audits['is-on-https'];
+      assert.strictEqual(auditResult.score, null);
+      assert.strictEqual(auditResult.scoreDisplayMode, 'error');
+      assert.strictEqual(auditResult.errorMessage, 'Required devtoolsLogs gatherer did not run.');
+    });
+
     it('outputs an error audit result when missing a required artifact', () => {
       const config = new Config({
         settings: {