Repository: GoogleChrome/lighthouse
Pull request #9354 (2019-07-11 18:34:19)

report: source-location for linkifying
Linkify source code in DevTools.

Fixes: #6436, second half of #5443, 

I've done `font-size` here. I haven't explored what other audits this could be done for. `font-size` only applies to CSS, but the changes here should be sufficient for JS/HTML too. 

I've also made the line/columns that `font-size` reports more accurate. Namely:

1) For inline styles, the stylesheet's `startColumn` was always being add to the rule's column. However, this is only valid if the stylesheet and the rule begin on the same line in the HTML.
2) For inline styles with a sourceURL magic comment, the line/column was relative to the HTML file. I've changed it to be relative to the beginning of the style tag, as if it were its own file. This may be worse for UX (harder to pinpoint where to look in the HTML?). Open to tweaking this - note that, the data `{url: <source mapped url>, line: <line relative to script tag>, column: <ditto>}` is necessary for DevTools to link to this file in the Source panel, so without this change that gets complicated.

LH report (outside DevTools) before/after ...

Before:

![image](https://user-images.githubusercontent.com/4071474/61431762-74ac4400-a8e3-11e9-9d48-627e0dc38f10.png)

After:

http://misc-hoten.surge.sh/lh-reports/pr-9354.html
![image](https://user-images.githubusercontent.com/4071474/61431821-8a216e00-a8e3-11e9-9ca5-bf2a15f3960e.png)


GIF of it working in DevTools:

![lh-dt-ui-location-linkify](https://user-images.githubusercontent.com/4071474/61075724-7a7bc400-a3cf-11e9-9634-a6e0e7235534.gif)

Associated Chromium changes: https://chromium-review.googlesource.com/c/chromium/src/+/1694144

Test it out:





```
node lighthouse-cli/ http://misc-hoten.surge.sh/lh-ui-location-font-size/ --only-audits=font-size --view
```

brendankenny: > I've also made the line/columns that font-size reports more accurate.

can we deal with these separately? They seem related but with significantly different concerns (audit internals vs report rendering/devtools integration)
connorjclark: > can we deal with these separately? They seem related but with significantly different concerns (audit internals vs report rendering/devtools integration)

Done! #9356
connorjclark: This is ready for review now.

http://misc-hoten.surge.sh/lh-reports/pr-9354.html

![image](https://user-images.githubusercontent.com/4071474/61431673-20a15f80-a8e3-11e9-9d88-47697bb32a10.png)

googlebot: All (the pull request submitter and all commit authors) CLAs are signed, **but** 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 by leaving a comment that contains only `@googlebot I consent.` in this pull request.

*Note to project maintainer:* There may be cases where the author cannot leave a comment, or the comment is not properly detected as consent.  In those cases, you can manually confirm consent of the commit author(s), and set the `cla` label to `yes` (if enabled on your project).

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

<!-- need_author_consent -->
googlebot: A Googler has manually verified that the CLAs look good.

(Googler, please make sure the reason for overriding the CLA status is clearly documented in these comments.)

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

<!-- cla_yes -->
googlebot: CLAs look good, thanks!

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

<!-- ok -->
connorjclark: deprecations audit uses it now too:

![image](https://user-images.githubusercontent.com/4071474/63066377-ac94b000-bebe-11e9-8edb-dfefe7b97c82.png)

Except it looks bad.
connorjclark: FYI - the line/col numbers in stack frames from inline scripts are made relative to the HTML document by V8, so no need to manually fix that like there was with stylesheets :)

![image](https://user-images.githubusercontent.com/4071474/63111197-07bbb680-bf42-11e9-9ca8-1e377205c0e5.png)

![image](https://user-images.githubusercontent.com/4071474/63111206-0d190100-bf42-11e9-891f-d3955242f89e.png)


connorjclark: @paulirish 	@brendankenny 	what are you desired changes here?
paulirish: @brendankenny can you take a look
paulirish: @brendankenny we're gonna merge tomorrow, FYI

Patch:
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index ce5b6d2ff..218f3a4c0 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,6 +1,6 @@
 {
   "name": "__MSG_appName__",
-  "version": "5.6.0",
+  "version": "100.0.0.0",
   "minimum_chrome_version": "72",
   "manifest_version": 2,
   "description": "__MSG_appDescription__",
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 1c5109678..47c93c482 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -88,7 +88,11 @@ function fillDevToolsShortcut() {
   el.textContent = isMac ? '⌘⌥I (Cmd+Opt+I)' : 'F12';
 }
 
-function persistSettings() {
+/**
+ * Create the settings from the state of the options form, save in storage, and return it.
+ * @returns {SettingsController.Settings}
+ */
+function readSettingsFromDomAndPersist() {
   const optionsEl = find('.section--options');
   // Save settings when options page is closed.
   const checkboxes = /** @type {NodeListOf<HTMLInputElement>} */
@@ -96,10 +100,12 @@ function persistSettings() {
   const selectedCategories = Array.from(checkboxes).map(input => input.value);
   const device = /** @type {HTMLInputElement} */ (find('input[name="device"]:checked')).value;
 
-  SettingsController.saveSettings({
+  const settings = {
     selectedCategories,
     device,
-  });
+  };
+  SettingsController.saveSettings(settings);
+  return settings;
 }
 
 /**
@@ -171,7 +177,7 @@ async function initPopup() {
   });
 
   optionsFormEl.addEventListener('change', () => {
-    persistSettings();
+    settings = readSettingsFromDomAndPersist();
   });
 }
 
diff --git a/docs/releasing.md b/docs/releasing.md
index 0044e992a..c1deb4cad 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -137,7 +137,7 @@ npm publish
 # Publish viewer.
 yarn deploy-viewer
 
-# Publish the extension.
+# Publish the extension (if it changed).
 open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
 cd dist/extension-package/
 echo "Upload the package zip to CWS dev dashboard..."
@@ -145,7 +145,7 @@ echo "Upload the package zip to CWS dev dashboard..."
 # Open <https://chrome.google.com/webstore/developer/dashboard>
 # Click _Edit_ on lighthouse
 # _Upload Updated Package_
-# Select `lighthouse-X.X.X.zip`
+# Select `lighthouse-X.X.X.X.zip`
 # _Publish_ at the bottom
 
 # * Tell the world!!! *
diff --git a/lighthouse-core/audits/deprecations.js b/lighthouse-core/audits/deprecations.js
index 381be4c06..9721c71e8 100644
--- a/lighthouse-core/audits/deprecations.js
+++ b/lighthouse-core/audits/deprecations.js
@@ -57,19 +57,33 @@ class Deprecations extends Audit {
     const entries = artifacts.ConsoleMessages;
 
     const deprecations = entries.filter(log => log.entry.source === 'deprecation').map(log => {
+      // HTML deprecations will have no url and no way to attribute to a specific line.
+      /** @type {LH.Audit.Details.SourceLocationValue=} */
+      let source;
+      if (log.entry.url) {
+        // JS deprecations will have a stack trace.
+        // CSS deprecations only expose a line number.
+        const topCallFrame = log.entry.stackTrace && log.entry.stackTrace.callFrames[0];
+        const line = log.entry.lineNumber || 0;
+        const column = topCallFrame ? topCallFrame.columnNumber : 0;
+        source = {
+          type: 'source-location',
+          url: log.entry.url,
+          urlProvider: 'network',
+          line,
+          column,
+        };
+      }
       return {
         value: log.entry.text,
-        url: log.entry.url || '',
-        source: log.entry.source,
-        lineNumber: log.entry.lineNumber,
+        source,
       };
     });
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'value', itemType: 'text', text: str_(UIStrings.columnDeprecate)},
-      {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
-      {key: 'lineNumber', itemType: 'text', text: str_(UIStrings.columnLine)},
+      {key: 'source', itemType: 'source-location', text: str_(i18n.UIStrings.columnURL)},
     ];
     const details = Audit.makeTableDetails(headings, deprecations);
 
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index a6eab51f9..cff60a696 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -7,7 +7,6 @@
 
 /** @typedef {LH.Artifacts.FontSize['analyzedFailingNodesData'][0]} FailingNodeData */
 
-const URL = require('../../lib/url-shim.js');
 const i18n = require('../../lib/i18n/i18n.js');
 const Audit = require('../audit.js');
 const ComputedViewportMeta = require('../../computed/viewport-meta.js');
@@ -119,80 +118,84 @@ function nodeToTableNode(node) {
  * @param {string} baseURL
  * @param {FailingNodeData['cssRule']} styleDeclaration
  * @param {FailingNodeData['node']} node
- * @returns {{source: string, selector: string | {type: 'node', selector: string, snippet: string}}}
+ * @returns {{source: LH.Audit.Details.UrlValue | LH.Audit.Details.SourceLocationValue | LH.Audit.Details.CodeValue, selector: string | LH.Audit.Details.NodeValue}}
  */
 function findStyleRuleSource(baseURL, styleDeclaration, node) {
-  if (
-    !styleDeclaration ||
+  if (!styleDeclaration ||
     styleDeclaration.type === 'Attributes' ||
     styleDeclaration.type === 'Inline'
   ) {
     return {
+      source: {type: 'url', value: baseURL},
       selector: nodeToTableNode(node),
-      source: baseURL,
     };
   }
 
   if (styleDeclaration.parentRule &&
     styleDeclaration.parentRule.origin === 'user-agent') {
     return {
+      source: {type: 'code', value: 'User Agent Stylesheet'},
       selector: styleDeclaration.parentRule.selectors.map(item => item.text).join(', '),
-      source: 'User Agent Stylesheet',
     };
   }
 
-  if (styleDeclaration.type === 'Regular' && styleDeclaration.parentRule) {
+  // Combine all the selectors for the associated style rule
+  // example: .some-selector, .other-selector {...} => `.some-selector, .other-selector`
+  let selector = '';
+  if (styleDeclaration.parentRule) {
     const rule = styleDeclaration.parentRule;
-    const stylesheet = styleDeclaration.stylesheet;
-
-    if (stylesheet) {
-      let source;
-      const selector = rule.selectors.map(item => item.text).join(', ');
-
-      if (stylesheet.sourceURL) {
-        const url = new URL(stylesheet.sourceURL, baseURL);
-        const range = styleDeclaration.range;
-        source = `${url.href}`;
-
-        // !!range == has defined location in a source file (.css or .html)
-        if (range) {
-          let line = range.startLine + 1;
-          let column = range.startColumn;
-
-          // Add the startLine/startColumn of the <style> element to the range, if stylesheet
-          // is inline.
-          // Always use the rule's location if a sourceURL magic comment is
-          // present (`hasSourceURL` is true) - this makes the line/col relative to the start
-          // of the style tag, which makes them relevant when the "file" is open in DevTool's
-          // Sources panel.
-          const addHtmlLocationOffset = stylesheet.isInline && !stylesheet.hasSourceURL;
-          if (addHtmlLocationOffset) {
-            line += stylesheet.startLine;
-            // The column the stylesheet begins on is only relevant if the rule is declared on the same line.
-            if (range.startLine === 0) {
-              column += stylesheet.startColumn;
-            }
-          }
-
-          source += `:${line}:${column}`;
-        }
-      } else {
-        // dynamically injected to page
-        source = 'dynamic';
-      }
+    selector = rule.selectors.map(item => item.text).join(', ');
+  }
 
-      return {
-        selector,
-        source,
-      };
+  if (styleDeclaration.stylesheet && !styleDeclaration.stylesheet.sourceURL) {
+    // Dynamically injected into page.
+    return {
+      source: {type: 'code', value: 'dynamic'},
+      selector,
+    };
+  }
+
+  // !!range == has defined location in a source file (.css or .html)
+  // sourceURL == stylesheet URL || raw value of magic `sourceURL` comment
+  // hasSourceURL == flag that signals sourceURL is the raw value of a magic `sourceURL` comment, *not* a real resource
+  if (styleDeclaration.stylesheet && styleDeclaration.range) {
+    const {range, stylesheet} = styleDeclaration;
+
+    // DevTools protocol does not provide the resource URL if there is a magic `sourceURL` comment.
+    // `sourceURL` will be the raw value of the magic `sourceURL` comment, which likely refers to
+    // a file at build time, not one that is served over the network that we could link to.
+    const urlProvider = stylesheet.hasSourceURL ? 'comment' : 'network';
+
+    let line = range.startLine;
+    let column = range.startColumn;
+
+    // Add the startLine/startColumn of the <style> element to the range, if stylesheet
+    // is inline.
+    // Always use the rule's location if a sourceURL magic comment is
+    // present (`hasSourceURL` is true) - this makes the line/col relative to the start
+    // of the style tag, which makes them relevant when the "file" is open in DevTool's
+    // Sources panel.
+    const addHtmlLocationOffset = stylesheet.isInline && urlProvider !== 'comment';
+    if (addHtmlLocationOffset) {
+      line += stylesheet.startLine;
+      // The column the stylesheet begins on is only relevant if the rule is declared on the same line.
+      if (range.startLine === 0) {
+        column += stylesheet.startColumn;
+      }
     }
+
+    const url = stylesheet.sourceURL;
+    return {
+      source: {type: 'source-location', url, urlProvider, line, column},
+      selector,
+    };
   }
 
   // The responsible style declaration was not captured in the font-size gatherer due to
   // the rate limiting we do in `fetchFailingNodeSourceRules`.
   return {
-    selector: '',
-    source: 'Unknown',
+    selector,
+    source: {type: 'code', value: 'Unknown'},
   };
 }
 
@@ -268,7 +271,7 @@ class FontSize extends Audit {
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
-      {key: 'source', itemType: 'url', text: 'Source'},
+      {key: 'source', itemType: 'source-location', text: 'Source'},
       {key: 'selector', itemType: 'code', text: 'Selector'},
       {key: 'coverage', itemType: 'text', text: '% of Page Text'},
       {key: 'fontSize', itemType: 'text', text: 'Font Size'},
@@ -293,7 +296,8 @@ class FontSize extends Audit {
         (failingTextLength - analyzedFailingTextLength) / visitedTextLength * 100;
 
       tableData.push({
-        source: 'Add\'l illegible text',
+        // Overrides default `source-location`
+        source: {type: 'code', value: 'Add\'l illegible text'},
         selector: '',
         coverage: `${percentageOfUnanalyzedFailingText.toFixed(2)}%`,
         fontSize: '< 12px',
@@ -302,7 +306,8 @@ class FontSize extends Audit {
 
     if (percentageOfPassingText > 0) {
       tableData.push({
-        source: 'Legible text',
+        // Overrides default `source-location`
+        source: {type: 'code', value: 'Legible text'},
         selector: '',
         coverage: `${percentageOfPassingText.toFixed(2)}%`,
         fontSize: '≥ 12px',
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index fddd79d4c..578342747 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -51,6 +51,7 @@ const defaultSettings = {
   gatherMode: false,
   disableStorageReset: false,
   emulatedFormFactor: 'mobile',
+  internalDisableDeviceScreenEmulation: false,
   channel: 'node',
 
   // the following settings have no defaults but we still want ensure that `key in settings`
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 6b4b42378..ba9a6e421 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -1352,12 +1352,7 @@ class Driver {
    * @return {Promise<void>}
    */
   async beginEmulation(settings) {
-    if (settings.emulatedFormFactor === 'mobile') {
-      await emulation.enableNexus5X(this);
-    } else if (settings.emulatedFormFactor === 'desktop') {
-      await emulation.enableDesktop(this);
-    }
-
+    await emulation.emulate(this, settings);
     await this.setThrottling(settings, {useThrottling: true});
   }
 
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index d5dbf3107..f0daa724e 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -62,34 +62,44 @@ const NO_CPU_THROTTLE_METRICS = {
   rate: 1,
 };
 
-/**
- * @param {Driver} driver
- * @return {Promise<void>}
- */
-async function enableNexus5X(driver) {
-  await Promise.all([
-    driver.sendCommand('Emulation.setDeviceMetricsOverride', NEXUS5X_EMULATION_METRICS),
-    // Network.enable must be called for UA overriding to work
-    driver.sendCommand('Network.enable'),
-    driver.sendCommand('Network.setUserAgentOverride', {userAgent: NEXUS5X_USERAGENT}),
-    driver.sendCommand('Emulation.setTouchEmulationEnabled', {enabled: true}),
-  ]);
-}
+const emulationParams = {
+  mobile: {
+    userAgent: NEXUS5X_USERAGENT,
+    metrics: NEXUS5X_EMULATION_METRICS,
+    touchEnabled: true,
+  },
+  desktop: {
+    userAgent: DESKTOP_USERAGENT,
+    metrics: DESKTOP_EMULATION_METRICS,
+    touchEnabled: false,
+  },
+};
 
 /**
+ *
  * @param {Driver} driver
+ * @param {LH.Config.Settings} settings
  * @return {Promise<void>}
  */
-async function enableDesktop(driver) {
-  await Promise.all([
-    driver.sendCommand('Emulation.setDeviceMetricsOverride', DESKTOP_EMULATION_METRICS),
-    // Network.enable must be called for UA overriding to work
-    driver.sendCommand('Network.enable'),
-    driver.sendCommand('Network.setUserAgentOverride', {userAgent: DESKTOP_USERAGENT}),
-    driver.sendCommand('Emulation.setTouchEmulationEnabled', {enabled: false}),
-  ]);
+async function emulate(driver, settings) {
+  if (!settings.emulatedFormFactor || settings.emulatedFormFactor === 'none') return;
+  const params = emulationParams[settings.emulatedFormFactor];
+
+  // In DevTools, emulation is applied before Lighthouse starts (to deal with viewport emulation bugs)
+  // As a result, we don't double-apply viewport emulation (devtools sets `internalDisableDeviceScreenEmulation`).
+  // UA emulation, however, is lost in the protocol handover from devtools frontend to the audits_worker. So it's always applied.
+
+  // Network.enable must be called for UA overriding to work
+  await driver.sendCommand('Network.enable');
+  await driver.sendCommand('Network.setUserAgentOverride', {userAgent: params.userAgent});
+
+  if (!settings.internalDisableDeviceScreenEmulation) {
+    await driver.sendCommand('Emulation.setDeviceMetricsOverride', params.metrics);
+    await driver.sendCommand('Emulation.setTouchEmulationEnabled', {enabled: params.touchEnabled});
+  }
 }
 
+
 /**
  * @param {Driver} driver
  * @param {Required<LH.ThrottlingSettings>} throttlingSettings
@@ -145,8 +155,7 @@ function disableCPUThrottling(driver) {
 }
 
 module.exports = {
-  enableNexus5X,
-  enableDesktop,
+  emulate,
   enableNetworkThrottling,
   clearAllNetworkEmulation,
   enableCPUThrottling,
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 5f5cd7910..e2a506cfe 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -230,6 +230,9 @@ class DetailsRenderer {
         case 'node': {
           return this.renderNode(value);
         }
+        case 'source-location': {
+          return this.renderSourceLocation(value);
+        }
         case 'url': {
           return this.renderTextURL(value.value);
         }
@@ -393,6 +396,36 @@ class DetailsRenderer {
     return element;
   }
 
+  /**
+   * @param {LH.Audit.Details.SourceLocationValue} item
+   * @return {Element|null}
+   * @protected
+   */
+  renderSourceLocation(item) {
+    if (!item.url) {
+      return null;
+    }
+
+    // Lines are shown as one-indexed.
+    const line = item.line + 1;
+    const column = item.column;
+
+    let element;
+    if (item.urlProvider === 'network') {
+      element = this.renderTextURL(item.url);
+      this._dom.find('a', element).textContent += `:${line}:${column}`;
+    } else {
+      element = this._renderText(`${item.url}:${line}:${column} (from sourceURL)`);
+    }
+
+    element.classList.add('lh-source-location');
+    element.setAttribute('data-source-url', item.url);
+    // DevTools expects zero-indexed lines.
+    element.setAttribute('data-source-line', String(item.line));
+    element.setAttribute('data-source-column', String(item.column));
+    return element;
+  }
+
   /**
    * @param {LH.Audit.Details.Filmstrip} details
    * @return {Element}
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index e848b3f7e..1ce201196 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -213,7 +213,8 @@ class ReportUIFeatures {
     /** @type {Array<HTMLTableElement>} */
     const tables = Array.from(this._document.querySelectorAll('.lh-table'));
     const tablesWithUrls = tables
-      .filter(el => el.querySelector('td.lh-table-column--url'))
+      .filter(el =>
+        el.querySelector('td.lh-table-column--url, td.lh-table-column--source-location'))
       .filter(el => {
         const containingAudit = el.closest('.lh-audit');
         if (!containingAudit) throw new Error('.lh-table not within audit');
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index e6d40ba60..0ecb30f03 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1335,6 +1335,7 @@
 
 /* Looks unnecessary, but mostly for keeping the <th>s left-aligned */
 .lh-table-column--text,
+.lh-table-column--source-location,
 .lh-table-column--url,
 /* .lh-table-column--thumbnail, */
 /* .lh-table-column--empty,*/
diff --git a/lighthouse-core/scripts/release/prepare-commit.sh b/lighthouse-core/scripts/release/prepare-commit.sh
index 314831f22..52a782994 100755
--- a/lighthouse-core/scripts/release/prepare-commit.sh
+++ b/lighthouse-core/scripts/release/prepare-commit.sh
@@ -40,11 +40,11 @@ git checkout -b "$BRANCH_NAME"
 # Install the dependencies.
 yarn install
 
-# Bump the version in package.json and clients/extension/manifest.json
+# Bump the version in package.json
 NEEDLE="^  \"version\": \"$SEMVER_PATTERN\""
 REPLACEMENT="  \"version\": \"$NEW_VERSION\""
 
-sed -i '' "s/$NEEDLE/$REPLACEMENT/g" package.json clients/extension/manifest.json
+sed -i '' "s/$NEEDLE/$REPLACEMENT/g" package.json
 
 # Update the fixtures with the new version
 yarn update:sample-json
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3f53b1382..4d1b4bd54 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -456,31 +456,33 @@
                         "text": "Deprecation / Warning"
                     },
                     {
-                        "itemType": "url",
-                        "key": "url",
+                        "itemType": "source-location",
+                        "key": "source",
                         "text": "URL"
-                    },
-                    {
-                        "itemType": "text",
-                        "key": "lineNumber",
-                        "text": "Line"
                     }
                 ],
                 "items": [
                     {
-                        "lineNumber": 322.0,
-                        "source": "deprecation",
-                        "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                        "source": {
+                            "column": 6.0,
+                            "line": 322.0,
+                            "type": "source-location",
+                            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                            "urlProvider": "network"
+                        },
                         "value": "Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user's experience. For more help, check https://xhr.spec.whatwg.org/."
                     },
                     {
-                        "lineNumber": 325.0,
-                        "source": "deprecation",
-                        "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                        "source": {
+                            "column": 9.0,
+                            "line": 325.0,
+                            "type": "source-location",
+                            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                            "urlProvider": "network"
+                        },
                         "value": "'window.webkitStorageInfo' is deprecated. Please use 'navigator.webkitTemporaryStorage' or 'navigator.webkitPersistentStorage' instead."
                     },
                     {
-                        "source": "deprecation",
                         "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details."
                     }
                 ],
@@ -818,7 +820,7 @@
             "details": {
                 "headings": [
                     {
-                        "itemType": "url",
+                        "itemType": "source-location",
                         "key": "source",
                         "text": "Source"
                     },
@@ -842,7 +844,10 @@
                     {
                         "coverage": "100.00%",
                         "fontSize": "\u2265 12px",
-                        "source": "Legible text"
+                        "source": {
+                            "type": "code",
+                            "value": "Legible text"
+                        }
                     }
                 ],
                 "type": "table"
diff --git a/readme.md b/readme.md
index 0471b09f7..40ca56d24 100644
--- a/readme.md
+++ b/readme.md
@@ -320,7 +320,8 @@ Other awesome open source projects that use Lighthouse.
 * **[lighthouse-batch](https://www.npmjs.com/package/lighthouse-batch)** - Run Lighthouse over a number of sites and generate a summary of their metrics/scores.
 * **[lighthouse-check-action](https://github.com/foo-software/lighthouse-check-action)** - A Github Action to run Lighthouse in a workflow, featuring Slack notifications and report upload to S3.
 * **[lighthouse-check-orb](https://circleci.com/orbs/registry/orb/foo-software/lighthouse-check)** - A CircleCI Orb to run Lighthouse in a workflow, featuring Slack notifications and report upload to S3.
-* **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
+* **[andreasonny83/lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
+* **[GoogleChrome/lighthouse-ci](https://github.com/GoogleChrome/lighthouse-ci)** - (**official**) Automate running Lighthouse for every commit, viewing the changes, and preventing regressions.
 * **[lighthouse-ci-action](https://github.com/treosh/lighthouse-ci-action)** - A Github Action that makes it easy to run Lighthouse in CI and keep your pages small using performance budgets.
 * **[lighthouse-cron](https://github.com/thearegee/lighthouse-cron)** - Cron multiple batch Lighthouse audits and emit results for sending to remote server.
 * **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index 0a58c1267..6b1820afd 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -82,7 +82,7 @@ declare global {
       }
 
       /** Possible types of values found within table items. */
-      type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url';
+      type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'source-location' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url';
 
       // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change.
 
@@ -104,7 +104,7 @@ declare global {
 
       export type TableItem = {
         debugData?: DebugData;
-        [p: string]: string | number | boolean | undefined | DebugData | NodeValue | LinkValue | UrlValue | CodeValue;
+        [p: string]: undefined | string | number | boolean | undefined | DebugData | NodeValue | SourceLocationValue | LinkValue | UrlValue | CodeValue;
       }
 
       export interface OpportunityColumnHeading {
@@ -167,6 +167,20 @@ declare global {
         nodeLabel?: string;
       }
 
+      /**
+       * A value used within a details object, intended to be displayed as a URL
+       * encoded with line and column info (url:line:column).
+       */
+      export interface SourceLocationValue {
+        type: 'source-location';
+        /** urls from the network are always valid urls. otherwise, urls come from either a comment or header, and may not be well-formed. */
+        url: string;
+        /** 'network' when the url is the actual, observed resource url. 'comment' when the url comes from a sourceMapURL comment or X-SourceMap header */
+        urlProvider: 'network' | 'comment';
+        line: number;
+        column: number;
+      }
+
       /**
        * A value used within a details object, intended to be displayed as a
        * linkified URL, regardless of the controlling heading's valueType.
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 8e02b10a2..9faa358c4 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -101,8 +101,10 @@ declare global {
       gatherMode?: boolean | string;
       /** Flag indicating that the browser storage should not be reset for the audit. */
       disableStorageReset?: boolean;
-      /** The form factor the emulation should use. */
+      /** How emulation (useragent, device screen metrics, touch) should be applied. `none` indicates Lighthouse should leave the host browser as-is. */
       emulatedFormFactor?: 'mobile'|'desktop'|'none';
+      /** Dangerous setting only to be used by Lighthouse team. Disables the device metrics and touch emulation that emulatedFormFactor defines. Details in emulation.js */
+      internalDisableDeviceScreenEmulation?: boolean
       /** The method used to throttle the network. */
       throttlingMethod?: 'devtools'|'simulate'|'provided';
       /** The throttling config settings. */


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 a8a3b8d12..087a030dc 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1321,6 +1321,7 @@ Object {
     "emulatedFormFactor": "mobile",
     "extraHeaders": null,
     "gatherMode": false,
+    "internalDisableDeviceScreenEmulation": false,
     "locale": "en-US",
     "maxWaitForFcp": 30000,
     "maxWaitForLoad": 45000,
@@ -1455,6 +1456,7 @@ Object {
     "emulatedFormFactor": "mobile",
     "extraHeaders": null,
     "gatherMode": false,
+    "internalDisableDeviceScreenEmulation": false,
     "locale": "en-US",
     "maxWaitForFcp": 30000,
     "maxWaitForLoad": 45000,
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
index 1ecc918b4..1fc36155d 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
@@ -120,32 +120,52 @@ const expectations = [
           details: {
             items: [
               {
-                source: /seo-tester\.html.+:24:12$/,
+                source: {
+                  url: /seo-tester\.html.+$/,
+                  urlProvider: 'network',
+                  line: 23,
+                  column: 12,
+                },
                 selector: '.small',
                 fontSize: '11px',
               },
               {
-                source: /seo-tester\.html.+:28:55$/,
+                source: {
+                  url: /seo-tester\.html.+$/,
+                  urlProvider: 'network',
+                  line: 27,
+                  column: 55,
+                },
                 selector: '.small-2',
                 fontSize: '11px',
               },
               {
-                source: /seo-tester-inline-magic\.css:3:14$/,
+                source: {
+                  url: /seo-tester-inline-magic\.css$/,
+                  urlProvider: 'comment',
+                  line: 2,
+                  column: 14,
+                },
                 selector: '.small-3',
                 fontSize: '6px',
               },
               {
-                source: /seo-tester-styles-magic\.css:3:10$/,
+                source: {
+                  url: /seo-tester-styles-magic\.css$/,
+                  urlProvider: 'comment',
+                  line: 2,
+                  column: 10,
+                },
                 selector: '.small-4',
                 fontSize: '6px',
               },
               {
-                source: 'User Agent Stylesheet',
+                source: {type: 'code', value: 'User Agent Stylesheet'},
                 selector: 'h6',
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'body',
@@ -154,7 +174,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'font',
@@ -163,7 +183,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'body',
@@ -172,7 +192,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: 'Legible text',
+                source: {type: 'code', value: 'Legible text'},
                 selector: '',
                 fontSize: '≥ 12px',
               },
diff --git a/lighthouse-core/test/audits/deprecations-test.js b/lighthouse-core/test/audits/deprecations-test.js
index ab51ae8a1..c058482dd 100644
--- a/lighthouse-core/test/audits/deprecations-test.js
+++ b/lighthouse-core/test/audits/deprecations-test.js
@@ -33,8 +33,7 @@ describe('Console deprecations audit', () => {
     assert.equal(auditResult.score, 0);
     expect(auditResult.displayValue).toBeDisplayString('1 warning found');
     assert.equal(auditResult.details.items.length, 1);
-    assert.equal(auditResult.details.items[0].url, '');
-    assert.equal(auditResult.details.items[0].lineNumber, undefined);
+    assert.equal(auditResult.details.items[0].source, null);
   });
 
   it('fails when deprecation messages are found', () => {
@@ -61,7 +60,7 @@ describe('Console deprecations audit', () => {
             source: 'somethingelse',
             lineNumber: 789,
             url: 'http://example3.com',
-            text: 'Not a deprecation message 456',
+            text: 'Not a deprecation message 789',
           },
         },
       ],
@@ -69,7 +68,7 @@ describe('Console deprecations audit', () => {
     assert.equal(auditResult.score, 0);
     expect(auditResult.displayValue).toBeDisplayString('2 warnings found');
     assert.equal(auditResult.details.items.length, 2);
-    assert.equal(auditResult.details.items[0].url, URL);
-    assert.equal(auditResult.details.items[0].lineNumber, 123);
+    assert.equal(auditResult.details.items[0].source.url, URL);
+    assert.equal(auditResult.details.items[0].source.line, 123);
   });
 });
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 236266153..f0e77bbcc 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -157,7 +157,10 @@ describe('SEO: Font size audit', () => {
     const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 3);
-    assert.equal(auditResult.details.items[1].source, 'Add\'l illegible text');
+    assert.deepEqual(auditResult.details.items[1].source, {
+      type: 'code',
+      value: 'Add\'l illegible text',
+    });
     assert.equal(auditResult.details.items[1].coverage, '40.00%');
     expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
@@ -264,7 +267,10 @@ describe('SEO: Font size audit', () => {
       });
 
       expect(auditResult.details.items[0]).toMatchObject({
-        source: URL.finalUrl,
+        source: {
+          type: 'url',
+          value: URL.finalUrl,
+        },
         selector: {
           type: 'node',
           selector: '#my-parent',
@@ -283,7 +289,10 @@ describe('SEO: Font size audit', () => {
       });
 
       expect(auditResult.details.items[0]).toMatchObject({
-        source: URL.finalUrl,
+        source: {
+          type: 'url',
+          value: URL.finalUrl,
+        },
         selector: {
           type: 'node',
           selector: '#my-parent',
@@ -291,5 +300,103 @@ describe('SEO: Font size audit', () => {
         },
       });
     });
+
+    it('to external stylesheet', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com/styles-1.css',
+        },
+        type: 'Regular',
+        range: {
+          startLine: 50,
+          startColumn: 50,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com/styles-1.css',
+        urlProvider: 'network',
+        line: 50,
+        column: 50,
+      });
+    });
+
+    it('to inline <style>', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com',
+          isInline: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 10,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com',
+        urlProvider: 'network',
+        line: 15,
+        column: 10,
+      });
+    });
+
+    it('to inline <style> with rule on the same line as <style>', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com',
+          isInline: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 0,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com',
+        urlProvider: 'network',
+        line: 5,
+        column: 15,
+      });
+    });
+
+    it('to inline <style> with magic sourceURL comment', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'something-magical.css',
+          isInline: true,
+          hasSourceURL: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 10,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'something-magical.css',
+        urlProvider: 'comment',
+        line: 10,
+        column: 10,
+      });
+    });
   });
 });
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 993352e93..66095f8b1 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -39,6 +39,7 @@ class TestGathererNoArtifact extends Gatherer {
 const fakeDriver = require('./fake-driver.js');
 const fakeDriverUsingRealMobileDevice = fakeDriver.fakeDriverUsingRealMobileDevice;
 
+// TODO: Refactor this to use gather/mock-commands.js
 function getMockedEmulationDriver(emulationFn, netThrottleFn, cpuThrottleFn,
   blockUrlFn, extraHeadersFn) {
   const Driver = require('../../gather/driver.js');
@@ -189,7 +190,7 @@ describe('GatherRunner', function() {
       const driver = fakeDriver;
       const config = new Config({
         passes: [],
-        settings: {emulatedFormFactor: 'none'},
+        settings: {emulatedFormFactor: 'none', internalDisableDeviceScreenEmulation: false},
       });
       const options = {requestedUrl, driver, settings: config.settings};
 
@@ -201,7 +202,7 @@ describe('GatherRunner', function() {
       const driver = fakeDriver;
       const config = new Config({
         passes: [],
-        settings: {emulatedFormFactor: 'mobile'},
+        settings: {emulatedFormFactor: 'mobile', internalDisableDeviceScreenEmulation: false},
       });
       const options = {requestedUrl, driver, settings: config.settings};
 
@@ -213,7 +214,7 @@ describe('GatherRunner', function() {
       const driver = fakeDriverUsingRealMobileDevice;
       const config = new Config({
         passes: [],
-        settings: {emulatedFormFactor: 'none'},
+        settings: {emulatedFormFactor: 'none', internalDisableDeviceScreenEmulation: false},
       });
       const options = {requestedUrl, driver, settings: config.settings};
 
@@ -225,7 +226,7 @@ describe('GatherRunner', function() {
       const driver = fakeDriverUsingRealMobileDevice;
       const config = new Config({
         passes: [],
-        settings: {emulatedFormFactor: 'desktop'},
+        settings: {emulatedFormFactor: 'desktop', internalDisableDeviceScreenEmulation: false},
       });
       const options = {requestedUrl, driver, settings: config.settings};
 
@@ -284,7 +285,10 @@ describe('GatherRunner', function() {
     );
 
     return GatherRunner.setupDriver(driver, {
-      settings: {emulatedFormFactor: 'mobile'},
+      settings: {
+        emulatedFormFactor: 'mobile',
+        internalDisableDeviceScreenEmulation: false,
+      },
     }).then(_ => {
       assert.ok(tests.calledDeviceEmulation, 'did not call device emulation');
       assert.deepEqual(tests.calledNetworkEmulation, {
@@ -294,23 +298,28 @@ describe('GatherRunner', function() {
     });
   });
 
-  it('uses correct emulation form factor', async () => {
-    let emulationParams;
+  it('applies the correct emulation given a particular emulationFormFactor', async () => {
+    const emulationFn = jest.fn();
     const driver = getMockedEmulationDriver(
-      params => emulationParams = params,
+      emulationFn,
       () => true,
       () => true
     );
 
-    await GatherRunner.setupDriver(driver, {settings: {emulatedFormFactor: 'mobile'}});
-    expect(emulationParams).toMatchObject({mobile: true});
+    const getSettings = formFactor => ({
+      emulatedFormFactor: formFactor,
+      internalDisableDeviceScreenEmulation: false,
+    });
+
+    await GatherRunner.setupDriver(driver, {settings: getSettings('mobile')});
+    expect(emulationFn.mock.calls.slice(-1)[0][0]).toMatchObject({mobile: true});
 
-    await GatherRunner.setupDriver(driver, {settings: {emulatedFormFactor: 'desktop'}});
-    expect(emulationParams).toMatchObject({mobile: false});
+    await GatherRunner.setupDriver(driver, {settings: getSettings('desktop')});
+    expect(emulationFn.mock.calls.slice(-1)[0][0]).toMatchObject({mobile: false});
 
-    emulationParams = undefined;
-    await GatherRunner.setupDriver(driver, {settings: {emulatedFormFactor: 'none'}});
-    expect(emulationParams).toBe(undefined);
+    emulationFn.mockClear();
+    await GatherRunner.setupDriver(driver, {settings: getSettings('none')});
+    expect(emulationFn).not.toHaveBeenCalled();
   });
 
   it('stops throttling when not devtools', () => {
@@ -333,6 +342,7 @@ describe('GatherRunner', function() {
       settings: {
         emulatedFormFactor: 'mobile',
         throttlingMethod: 'provided',
+        internalDisableDeviceScreenEmulation: false,
       },
     }).then(_ => {
       assert.ok(tests.calledDeviceEmulation, 'did not call device emulation');
@@ -362,7 +372,7 @@ describe('GatherRunner', function() {
 
     return GatherRunner.setupDriver(driver, {
       settings: {
-        emulatedFormFactor: 'mobile',
+        emulatedFormFactor: 'mobile', internalDisableDeviceScreenEmulation: false,
         throttlingMethod: 'devtools',
         throttling: {
           requestLatencyMs: 100,
diff --git a/lighthouse-core/test/lib/emulation-test.js b/lighthouse-core/test/lib/emulation-test.js
new file mode 100644
index 000000000..739a080cb
--- /dev/null
+++ b/lighthouse-core/test/lib/emulation-test.js
@@ -0,0 +1,109 @@
+/**
+ * @license Copyright 2019 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';
+
+const emulation = require('../../lib/emulation.js');
+const Driver = require('../../gather/driver.js');
+const Connection = require('../../gather/connections/connection.js');
+const {createMockSendCommandFn} = require('../gather/mock-commands.js');
+
+/* eslint-env jest */
+
+describe('emulation', () => {
+  describe('emulate', () => {
+    let driver;
+    let connectionStub;
+
+    beforeEach(() => {
+      connectionStub = new Connection();
+      connectionStub.sendCommand = cmd => {
+        throw new Error(`${cmd} not implemented`);
+      };
+      driver = new Driver(connectionStub);
+
+      connectionStub.sendCommand = createMockSendCommandFn()
+        .mockResponse('Network.enable')
+        .mockResponse('Network.setUserAgentOverride')
+        .mockResponse('Emulation.setDeviceMetricsOverride')
+        .mockResponse('Emulation.setTouchEmulationEnabled');
+    });
+
+    const getSettings = (formFactor, disableDeviceScreenEmulation) => ({
+      emulatedFormFactor: formFactor,
+      internalDisableDeviceScreenEmulation: disableDeviceScreenEmulation,
+    });
+
+    it('handles: emulatedFormFactor: mobile / disableDeviceScreenEmulation: false', async () => {
+      await emulation.emulate(driver, getSettings('mobile', false));
+
+      const uaArgs = connectionStub.sendCommand.findInvocation('Network.setUserAgentOverride');
+      expect(uaArgs).toMatchObject({userAgent: emulation.MOBILE_USERAGENT});
+
+      const emulateArgs = connectionStub.sendCommand.findInvocation(
+        'Emulation.setDeviceMetricsOverride'
+      );
+      expect(emulateArgs).toMatchObject({mobile: true});
+    });
+
+    it('handles: emulatedFormFactor: desktop / disableDeviceScreenEmulation: false', async () => {
+      await emulation.emulate(driver, getSettings('desktop', false));
+
+      const uaArgs = connectionStub.sendCommand.findInvocation('Network.setUserAgentOverride');
+      expect(uaArgs).toMatchObject({userAgent: emulation.DESKTOP_USERAGENT});
+
+      const emulateArgs = connectionStub.sendCommand.findInvocation(
+        'Emulation.setDeviceMetricsOverride'
+      );
+      expect(emulateArgs).toMatchObject({mobile: false});
+    });
+
+    it('handles: emulatedFormFactor: none / disableDeviceScreenEmulation: false', async () => {
+      await emulation.emulate(driver, getSettings('none', false));
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Network.setUserAgentOverride',
+        expect.anything()
+      );
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Emulation.setDeviceMetricsOverride',
+        expect.anything()
+      );
+    });
+
+    it('handles: emulatedFormFactor: mobile / disableDeviceScreenEmulation: true', async () => {
+      await emulation.emulate(driver, getSettings('mobile', true));
+      const uaArgs = connectionStub.sendCommand.findInvocation('Network.setUserAgentOverride');
+      expect(uaArgs).toMatchObject({userAgent: emulation.MOBILE_USERAGENT});
+
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Emulation.setDeviceMetricsOverride',
+        expect.anything()
+      );
+    });
+
+    it('handles: emulatedFormFactor: desktop / disableDeviceScreenEmulation: true', async () => {
+      await emulation.emulate(driver, getSettings('desktop', true));
+      const uaArgs = connectionStub.sendCommand.findInvocation('Network.setUserAgentOverride');
+      expect(uaArgs).toMatchObject({userAgent: emulation.DESKTOP_USERAGENT});
+
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Emulation.setDeviceMetricsOverride',
+        expect.anything()
+      );
+    });
+
+    it('handles: emulatedFormFactor: none / disableDeviceScreenEmulation: true', async () => {
+      await emulation.emulate(driver, getSettings('none', true));
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Network.setUserAgentOverride',
+        expect.anything()
+      );
+      expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+        'Emulation.setDeviceMetricsOverride',
+        expect.anything()
+      );
+    });
+  });
+});
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 27660ea05..3dd2058b5 100644
--- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
@@ -393,6 +393,56 @@ describe('DetailsRenderer', () => {
       assert.equal(nodeEl.getAttribute('data-snippet'), node.snippet);
     });
 
+    it('renders source-location values', () => {
+      const sourceLocation = {
+        type: 'source-location',
+        url: 'https://www.example.com/script.js',
+        urlProvider: 'network',
+        line: 10,
+        column: 5,
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'source-location', text: 'Heading'}],
+        items: [{content: sourceLocation}],
+      };
+
+      const el = renderer.render(details);
+      const sourceLocationEl = el.querySelector('.lh-source-location');
+      const anchorEl = sourceLocationEl.querySelector('a');
+      assert.strictEqual(sourceLocationEl.localName, 'div');
+      assert.equal(anchorEl.href, 'https://www.example.com/script.js');
+      assert.equal(sourceLocationEl.textContent, '/script.js:11:5(www.example.com)');
+      assert.equal(sourceLocationEl.getAttribute('data-source-url'), sourceLocation.url);
+      assert.equal(sourceLocationEl.getAttribute('data-source-line'), sourceLocation.line);
+      assert.equal(sourceLocationEl.getAttribute('data-source-column'), sourceLocation.column);
+    });
+
+    it('renders source-location values that aren\'t network resources', () => {
+      const sourceLocation = {
+        type: 'source-location',
+        url: 'https://www.example.com/script.js',
+        urlProvider: 'comment',
+        line: 0,
+        column: 0,
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'source-location', text: 'Heading'}],
+        items: [{content: sourceLocation}],
+      };
+
+      const el = renderer.render(details);
+      const sourceLocationEl = el.querySelector('.lh-source-location');
+      const anchorEl = sourceLocationEl.querySelector('a');
+      assert.ok(!anchorEl);
+      assert.strictEqual(sourceLocationEl.localName, 'div');
+      assert.equal(sourceLocationEl.textContent, 'https://www.example.com/script.js:1:0 (from sourceURL)');
+      assert.equal(sourceLocationEl.getAttribute('data-source-url'), sourceLocation.url);
+      assert.equal(sourceLocationEl.getAttribute('data-source-line'), sourceLocation.line);
+      assert.equal(sourceLocationEl.getAttribute('data-source-column'), sourceLocation.column);
+    });
+
     it('renders text URL values from a string', () => {
       const urlText = 'https://example.com/';
       const displayUrlText = 'https://example.com';
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 26713ecbc..f3918e6d3 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -29,6 +29,7 @@
     "gatherMode": "lighthouse-core/test/results/artifacts",
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
+    "internalDisableDeviceScreenEmulation": false,
     "channel": "cli",
     "budgets": [
       {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 6c55b74de..c4ec2683b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -609,33 +609,34 @@
             "text": "Deprecation / Warning"
           },
           {
-            "key": "url",
-            "itemType": "url",
+            "key": "source",
+            "itemType": "source-location",
             "text": "URL"
-          },
-          {
-            "key": "lineNumber",
-            "itemType": "text",
-            "text": "Line"
           }
         ],
         "items": [
           {
             "value": "Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user's experience. For more help, check https://xhr.spec.whatwg.org/.",
-            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
-            "source": "deprecation",
-            "lineNumber": 322
+            "source": {
+              "type": "source-location",
+              "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+              "urlProvider": "network",
+              "line": 322,
+              "column": 6
+            }
           },
           {
             "value": "'window.webkitStorageInfo' is deprecated. Please use 'navigator.webkitTemporaryStorage' or 'navigator.webkitPersistentStorage' instead.",
-            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
-            "source": "deprecation",
-            "lineNumber": 325
+            "source": {
+              "type": "source-location",
+              "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+              "urlProvider": "network",
+              "line": 325,
+              "column": 9
+            }
           },
           {
-            "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details.",
-            "url": "",
-            "source": "deprecation"
+            "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details."
           }
         ]
       }
@@ -3205,7 +3206,7 @@
         "headings": [
           {
             "key": "source",
-            "itemType": "url",
+            "itemType": "source-location",
             "text": "Source"
           },
           {
@@ -3226,7 +3227,10 @@
         ],
         "items": [
           {
-            "source": "Legible text",
+            "source": {
+              "type": "code",
+              "value": "Legible text"
+            },
             "selector": "",
             "coverage": "100.00%",
             "fontSize": "≥ 12px"
@@ -3376,6 +3380,7 @@
     "gatherMode": false,
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
+    "internalDisableDeviceScreenEmulation": false,
     "channel": "cli",
     "budgets": [
       {
@@ -5646,9 +5651,6 @@
       "lighthouse-core/audits/deprecations.js | columnDeprecate": [
         "audits.deprecations.details.headings[0].text"
       ],
-      "lighthouse-core/audits/deprecations.js | columnLine": [
-        "audits.deprecations.details.headings[2].text"
-      ],
       "lighthouse-core/audits/mainthread-work-breakdown.js | title": [
         "audits[mainthread-work-breakdown].title"
       ],