Repository: GoogleChrome/lighthouse
Pull request #12796 (2021-07-17 04:15:54)

core(dom): introduce safelySetHref
- introduce `safelySetHref` and change all `elem.href` setters to use it.  it ensures we don't set a link to a `javascript:evil()` url.
- add `safelySetBlobHref`. same kinda idea.
- remove the _bag of `attrs`_ method signature from createElement. it opens another vector towards accidentally setting [href/src/etc](https://github.com/google/safevalues/blob/da12e9896a244cd0ddce32b396652e43c2a7c41d/builders/safe_attribute_builders.ts#L25-L38) to something bad. it was unlikely, but that syntactic sugar didn't really save us much. :)

Relevant to cl/383899920 🔒 



Patch:
diff --git a/.github/workflows/devtools.yml b/.github/workflows/devtools.yml
index dcce705d0..f18415141 100644
--- a/.github/workflows/devtools.yml
+++ b/.github/workflows/devtools.yml
@@ -33,7 +33,7 @@ jobs:
           ${{ env.BLINK_TOOLS_PATH }}
           ${{ github.workspace }}/lighthouse/.tmp/chromium-web-tests/content-shells
         # The number is how many times this hash key was manually updated to break the cache.
-        key: ${{ runner.os }}-2-${{ hashFiles('third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/*.js', 'lighthouse/.github/workflows/devtools.yml', 'lighthouse/lighthouse-core/test/chromium-web-tests/*', 'lighthouse/clients/devtools-entry.js', 'lighthouse/clients/devtools-report-assets.js', 'lighthouse/build/build-bundle.js', 'lighthouse/build/build-dt-report-resources.js') }}
+        key: ${{ runner.os }}-2-${{ hashFiles('lighthouse/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/*.js', 'lighthouse/.github/workflows/devtools.yml', 'lighthouse/lighthouse-core/test/chromium-web-tests/*', 'lighthouse/clients/devtools-entry.js', 'lighthouse/clients/devtools-report-assets.js', 'lighthouse/build/build-bundle.js', 'lighthouse/build/build-dt-report-resources.js') }}
 
     - name: Use Node.js 12.x
       uses: actions/setup-node@v1
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 49756c9fd..aab061061 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -108,6 +108,11 @@ function getFlags(manualArgv, options = {}) {
           default: false,
           describe: 'Print the normalized config for the given config and options, then exit.',
         },
+        'fraggle-rock': {
+          type: 'boolean',
+          default: false,
+          describe: '[EXPERIMENTAL] Use the new Fraggle Rock navigation runner to gather results.',
+        },
         'additional-trace-categories': {
           type: 'string',
           describe: 'Additional categories to capture with the trace (comma-delimited).',
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index a0898ec09..0c780e610 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -195,6 +195,25 @@ async function potentiallyKillChrome(launchedChrome) {
   });
 }
 
+/**
+ * @param {string} url
+ * @param {LH.CliFlags} flags
+ * @param {LH.Config.Json|undefined} config
+ * @param {ChromeLauncher.LaunchedChrome} launchedChrome
+ * @return {Promise<LH.RunnerResult|undefined>}
+ */
+async function runLighthouseWithFraggleRock(url, flags, config, launchedChrome) {
+  const fraggleRock = require('../lighthouse-core/fraggle-rock/api.js');
+  const puppeteer = require('puppeteer');
+  // @ts-expect-error - FIXME: lighthouse-logger is greedy and takes over `debug` for all packages
+  require('debug').enable('-puppeteer:*');
+  const browser = await puppeteer.connect({browserURL: `http://localhost:${launchedChrome.port}`});
+  const page = await browser.newPage();
+  flags.channel = 'fraggle-rock-cli';
+  const configContext = {configPath: flags.configPath, settingsOverrides: flags};
+  return fraggleRock.navigation({url, page, config, configContext});
+}
+
 /**
  * @param {string} url
  * @param {LH.CliFlags} flags
@@ -223,7 +242,9 @@ async function runLighthouse(url, flags, config) {
       flags.port = launchedChrome.port;
     }
 
-    const runnerResult = await lighthouse(url, flags, config);
+    const runnerResult = flags.fraggleRock && launchedChrome ?
+       await runLighthouseWithFraggleRock(url, flags, config, launchedChrome) :
+       await lighthouse(url, flags, config);
 
     // If in gatherMode only, there will be no runnerResult.
     if (runnerResult) {
diff --git a/lighthouse-core/audits/csp-xss.js b/lighthouse-core/audits/csp-xss.js
index 93f83b488..e45fb0d8f 100644
--- a/lighthouse-core/audits/csp-xss.js
+++ b/lighthouse-core/audits/csp-xss.js
@@ -176,7 +176,7 @@ class CspXss extends Audit {
 
     return {
       score,
-      notApplicable: false,
+      notApplicable: !results.length,
       details,
     };
   }
diff --git a/lighthouse-core/fraggle-rock/config/config.js b/lighthouse-core/fraggle-rock/config/config.js
index db07e6a28..2d56c0cc2 100644
--- a/lighthouse-core/fraggle-rock/config/config.js
+++ b/lighthouse-core/fraggle-rock/config/config.js
@@ -163,7 +163,7 @@ function resolveNavigationsToDefns(navigations, artifactDefns) {
 
 /**
  * @param {LH.Config.Json|undefined} configJSON
- * @param {{gatherMode: LH.Gatherer.GatherMode, configPath?: string, settingsOverrides?: LH.SharedFlagsSettings}} context
+ * @param {Omit<LH.Config.FRContext, 'gatherMode'> & {gatherMode: LH.Gatherer.GatherMode}} context
  * @return {{config: LH.Config.FRConfig, warnings: string[]}}
  */
 function initializeConfig(configJSON, context) {
diff --git a/lighthouse-core/fraggle-rock/gather/navigation-runner.js b/lighthouse-core/fraggle-rock/gather/navigation-runner.js
index 9aa04b312..e7da3d17a 100644
--- a/lighthouse-core/fraggle-rock/gather/navigation-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/navigation-runner.js
@@ -239,12 +239,12 @@ async function _cleanup({requestedUrl, driver, config}) {
 }
 
 /**
- * @param {{url: string, page: import('puppeteer').Page, config?: LH.Config.Json}} options
+ * @param {{url: string, page: import('puppeteer').Page, config?: LH.Config.Json, configContext?: LH.Config.FRContext}} options
  * @return {Promise<LH.RunnerResult|undefined>}
  */
 async function navigation(options) {
-  const {url: requestedUrl, page} = options;
-  const {config} = initializeConfig(options.config, {gatherMode: 'navigation'});
+  const {url: requestedUrl, page, configContext = {}} = options;
+  const {config} = initializeConfig(options.config, {...configContext, gatherMode: 'navigation'});
   const computedCache = new Map();
 
   return Runner.run(
diff --git a/lighthouse-core/fraggle-rock/gather/snapshot-runner.js b/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
index be0b7f139..35c3916ae 100644
--- a/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
@@ -15,9 +15,10 @@ const {
 const {initializeConfig} = require('../config/config.js');
 const {getBaseArtifacts, finalizeArtifacts} = require('./base-artifacts.js');
 
-/** @param {{page: import('puppeteer').Page, config?: LH.Config.Json}} options */
+/** @param {{page: import('puppeteer').Page, config?: LH.Config.Json, configContext?: LH.Config.FRContext}} options */
 async function snapshot(options) {
-  const {config} = initializeConfig(options.config, {gatherMode: 'snapshot'});
+  const {configContext = {}} = options;
+  const {config} = initializeConfig(options.config, {...configContext, gatherMode: 'snapshot'});
   const driver = new Driver(options.page);
   await driver.connect();
 
diff --git a/lighthouse-core/fraggle-rock/gather/timespan-runner.js b/lighthouse-core/fraggle-rock/gather/timespan-runner.js
index 9fbb68276..2983aabf2 100644
--- a/lighthouse-core/fraggle-rock/gather/timespan-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/timespan-runner.js
@@ -16,11 +16,12 @@ const {initializeConfig} = require('../config/config.js');
 const {getBaseArtifacts, finalizeArtifacts} = require('./base-artifacts.js');
 
 /**
- * @param {{page: import('puppeteer').Page, config?: LH.Config.Json}} options
+ * @param {{page: import('puppeteer').Page, config?: LH.Config.Json, configContext?: LH.Config.FRContext}} options
  * @return {Promise<{endTimespan(): Promise<LH.RunnerResult|undefined>}>}
  */
 async function startTimespan(options) {
-  const {config} = initializeConfig(options.config, {gatherMode: 'timespan'});
+  const {configContext = {}} = options;
+  const {config} = initializeConfig(options.config, {...configContext, gatherMode: 'timespan'});
   const driver = new Driver(options.page);
   await driver.connect();
 
diff --git a/lighthouse-core/gather/gatherers/css-usage.js b/lighthouse-core/gather/gatherers/css-usage.js
index c83a0d8b0..06c9b4111 100644
--- a/lighthouse-core/gather/gatherers/css-usage.js
+++ b/lighthouse-core/gather/gatherers/css-usage.js
@@ -11,38 +11,83 @@ const FRGatherer = require('../../fraggle-rock/gather/base-gatherer.js');
  * @fileoverview Tracks unused CSS rules.
  */
 class CSSUsage extends FRGatherer {
+  constructor() {
+    super();
+    /** @type {Array<LH.Crdp.CSS.StyleSheetAddedEvent>} */
+    this._stylesheets = [];
+    /** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
+    this._onStylesheetAdded = sheet => this._stylesheets.push(sheet);
+    /**
+     * Initialize as undefined so we can assert results are fetched.
+     * @type {LH.Crdp.CSS.RuleUsage[]|undefined}
+     */
+    this._ruleUsage = undefined;
+  }
+
   /** @type {LH.Gatherer.GathererMeta} */
   meta = {
-    // TODO(FR-COMPAT): Add support for timespan.
-    supportedModes: ['snapshot', 'navigation'],
+    supportedModes: ['snapshot', 'timespan', 'navigation'],
   };
 
   /**
    * @param {LH.Gatherer.FRTransitionalContext} context
-   * @return {Promise<LH.Artifacts['CSSUsage']>}
    */
-  async getArtifact(context) {
+  async startCSSUsageTracking(context) {
     const session = context.driver.defaultSession;
-    const executionContext = context.driver.executionContext;
-
-    /** @type {Array<LH.Crdp.CSS.StyleSheetAddedEvent>} */
-    const stylesheets = [];
-    /** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
-    const onStylesheetAdded = sheet => stylesheets.push(sheet);
-    session.on('CSS.styleSheetAdded', onStylesheetAdded);
+    session.on('CSS.styleSheetAdded', this._onStylesheetAdded);
 
     await session.sendCommand('DOM.enable');
     await session.sendCommand('CSS.enable');
     await session.sendCommand('CSS.startRuleUsageTracking');
+  }
+
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext} context
+   */
+  async startInstrumentation(context) {
+    if (context.gatherMode !== 'timespan') return;
+    await this.startCSSUsageTracking(context);
+  }
+
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext} context
+   */
+  async stopCSSUsageTracking(context) {
+    const session = context.driver.defaultSession;
+    const coverageResponse = await session.sendCommand('CSS.stopRuleUsageTracking');
+    this._ruleUsage = coverageResponse.ruleUsage;
+    session.off('CSS.styleSheetAdded', this._onStylesheetAdded);
+  }
 
-    // Force style to recompute.
-    // Doesn't appear to be necessary in newer versions of Chrome.
-    await executionContext.evaluateAsync('getComputedStyle(document.body)');
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext} context
+   */
+  async stopInstrumentation(context) {
+    if (context.gatherMode !== 'timespan') return;
+    await this.stopCSSUsageTracking(context);
+  }
 
-    session.off('CSS.styleSheetAdded', onStylesheetAdded);
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext} context
+   * @return {Promise<LH.Artifacts['CSSUsage']>}
+   */
+  async getArtifact(context) {
+    const session = context.driver.defaultSession;
+    const executionContext = context.driver.executionContext;
+
+    // TODO(FR-COMPAT): Do this only for snapshot once legacy runner is deprecated.
+    if (context.gatherMode !== 'timespan') {
+      await this.startCSSUsageTracking(context);
+
+      // Force style to recompute.
+      // Doesn't appear to be necessary in newer versions of Chrome.
+      await executionContext.evaluateAsync('getComputedStyle(document.body)');
+
+      await this.stopCSSUsageTracking(context);
+    }
 
     // Fetch style sheet content in parallel.
-    const promises = stylesheets.map(sheet => {
+    const promises = this._stylesheets.map(sheet => {
       const styleSheetId = sheet.header.styleSheetId;
       return session.sendCommand('CSS.getStyleSheetText', {styleSheetId}).then(content => {
         return {
@@ -53,15 +98,17 @@ class CSSUsage extends FRGatherer {
     });
     const styleSheetInfo = await Promise.all(promises);
 
-    const ruleUsageResponse = await session.sendCommand('CSS.stopRuleUsageTracking');
     await session.sendCommand('CSS.disable');
     await session.sendCommand('DOM.disable');
 
     const dedupedStylesheets = new Map(styleSheetInfo.map(sheet => {
       return [sheet.content, sheet];
     }));
+
+    if (!this._ruleUsage) throw new Error('Issue collecting rule usages');
+
     return {
-      rules: ruleUsageResponse.ruleUsage,
+      rules: this._ruleUsage,
       stylesheets: Array.from(dedupedStylesheets.values()),
     };
   }
diff --git a/lighthouse-logger/index.js b/lighthouse-logger/index.js
index 7ae6ea5a5..f4f02c6be 100644
--- a/lighthouse-logger/index.js
+++ b/lighthouse-logger/index.js
@@ -61,6 +61,7 @@ class Log {
   }
 
   static loggerfn(title) {
+    title = `LH:${title}`;
     let log = loggersByTitle[title];
     if (!log) {
       log = debug(title);
@@ -82,16 +83,16 @@ class Log {
     level_ = level;
     switch (level) {
       case 'silent':
-        debug.enable('-*');
+        debug.enable('-LH:*');
         break;
       case 'verbose':
-        debug.enable('*');
+        debug.enable('LH:*');
         break;
       case 'error':
-        debug.enable('-*, *:error');
+        debug.enable('-LH:*, LH:*:error');
         break;
       default:
-        debug.enable('*, -*:verbose');
+        debug.enable('LH:*, -LH:*:verbose');
     }
   }
 
diff --git a/lighthouse-logger/package.json b/lighthouse-logger/package.json
index 41d4d0249..2456b62b7 100644
--- a/lighthouse-logger/package.json
+++ b/lighthouse-logger/package.json
@@ -1,6 +1,6 @@
 {
   "name": "lighthouse-logger",
-  "version": "1.2.0",
+  "version": "1.3.0",
   "license": "Apache-2.0",
   "dependencies": {
     "debug": "^2.6.9",
diff --git a/lighthouse-treemap/app/src/main.js b/lighthouse-treemap/app/src/main.js
index 2548f3f74..b986f4da2 100644
--- a/lighthouse-treemap/app/src/main.js
+++ b/lighthouse-treemap/app/src/main.js
@@ -646,16 +646,14 @@ function renderViewModeButtons(viewModes) {
     if (!viewMode.enabled) viewModeEl.classList.add('view-mode--disabled');
     viewModeEl.id = `view-mode--${viewMode.id}`;
 
-    const inputEl = TreemapUtil.createChildOf(viewModeEl, 'input', 'view-mode__button', {
-      id: `view-mode--${viewMode.id}__label`,
-      type: 'radio',
-      name: 'view-mode',
-      disabled: viewMode.enabled ? undefined : '',
-    });
-
-    const labelEl = TreemapUtil.createChildOf(viewModeEl, 'label', undefined, {
-      for: inputEl.id,
-    });
+    const inputEl = TreemapUtil.createChildOf(viewModeEl, 'input', 'view-mode__button');
+    inputEl.id = `view-mode--${viewMode.id}__label`;
+    inputEl.type = 'radio';
+    inputEl.name = 'view-mode';
+    inputEl.disabled = !viewMode.enabled;
+
+    const labelEl = TreemapUtil.createChildOf(viewModeEl, 'label');
+    labelEl.htmlFor = inputEl.id;
     TreemapUtil.createChildOf(labelEl, 'span', 'view-mode__label').textContent = viewMode.label;
     TreemapUtil.createChildOf(labelEl, 'span', 'view-mode__sublabel lh-text-dim').textContent =
       ` (${viewMode.subLabel})`;
diff --git a/lighthouse-treemap/app/src/util.js b/lighthouse-treemap/app/src/util.js
index b5f8dd43c..0e07dc17e 100644
--- a/lighthouse-treemap/app/src/util.js
+++ b/lighthouse-treemap/app/src/util.js
@@ -75,22 +75,13 @@ class TreemapUtil {
    * @template {string} T
    * @param {T} name
    * @param {string=} className
-   * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
-   *     Note: if an attribute key has an undefined value, this method does not
-   *     set the attribute on the node.
    * @return {HTMLElementByTagName[T]}
    */
-  static createElement(name, className, attrs = {}) {
+  static createElement(name, className) {
     const element = document.createElement(name);
     if (className) {
       element.className = className;
     }
-    Object.keys(attrs).forEach(key => {
-      const value = attrs[key];
-      if (typeof value !== 'undefined') {
-        element.setAttribute(key, value);
-      }
-    });
     return element;
   }
 
@@ -99,13 +90,10 @@ class TreemapUtil {
    * @param {Element} parentElem
    * @param {T} elementName
    * @param {string=} className
-   * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
-   *     Note: if an attribute key has an undefined value, this method does not
-   *     set the attribute on the node.
    * @return {HTMLElementByTagName[T]}
    */
-  static createChildOf(parentElem, elementName, className, attrs) {
-    const element = this.createElement(elementName, className, attrs);
+  static createChildOf(parentElem, elementName, className) {
+    const element = this.createElement(elementName, className);
     parentElem.appendChild(element);
     return element;
   }
diff --git a/report/renderer/category-renderer.js b/report/renderer/category-renderer.js
index 4d684ab0a..c3dfaafa6 100644
--- a/report/renderer/category-renderer.js
+++ b/report/renderer/category-renderer.js
@@ -82,9 +82,8 @@ export class CategoryRenderer {
     descEl.appendChild(this.dom.convertMarkdownLinkSnippets(audit.result.description));
 
     for (const relevantMetric of audit.relevantMetrics || []) {
-      const adornEl = this.dom.createChildOf(descEl, 'span', 'lh-audit__adorn', {
-        title: `Relevant to ${relevantMetric.result.title}`,
-      });
+      const adornEl = this.dom.createChildOf(descEl, 'span', 'lh-audit__adorn');
+      adornEl.title = `Relevant to ${relevantMetric.result.title}`;
       adornEl.textContent = relevantMetric.acronym || relevantMetric.id;
     }
 
@@ -332,7 +331,7 @@ export class CategoryRenderer {
   renderScoreGauge(category, groupDefinitions) { // eslint-disable-line no-unused-vars
     const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge', this.templateContext);
     const wrapper = this.dom.find('a.lh-gauge__wrapper', tmpl);
-    wrapper.href = `#${category.id}`;
+    this.dom.safelySetHref(wrapper, `#${category.id}`);
 
     if (Util.isPluginCategory(category.id)) {
       wrapper.classList.add('lh-gauge__wrapper--plugin');
diff --git a/report/renderer/details-renderer.js b/report/renderer/details-renderer.js
index dbbf2bc45..df34d249d 100644
--- a/report/renderer/details-renderer.js
+++ b/report/renderer/details-renderer.js
@@ -151,24 +151,19 @@ export class DetailsRenderer {
    * @return {HTMLElement}
    */
   _renderLink(details) {
-    const allowedProtocols = ['https:', 'http:'];
-    let url;
-    try {
-      url = new URL(details.url);
-    } catch (_) {}
+    const a = this._dom.createElement('a');
+    this._dom.safelySetHref(a, details.url);
 
-    if (!url || !allowedProtocols.includes(url.protocol)) {
+    if (!a.href) {
       // Fall back to just the link text if invalid or protocol not allowed.
       const element = this._renderText(details.text);
       element.classList.add('lh-link');
       return element;
     }
 
-    const a = this._dom.createElement('a');
     a.rel = 'noopener';
     a.target = '_blank';
     a.textContent = details.text;
-    a.href = url.href;
     a.classList.add('lh-link');
     return a;
   }
@@ -599,10 +594,9 @@ export class DetailsRenderer {
 
     for (const thumbnail of details.items) {
       const frameEl = this._dom.createChildOf(filmstripEl, 'div', 'lh-filmstrip__frame');
-      this._dom.createChildOf(frameEl, 'img', 'lh-filmstrip__thumbnail', {
-        src: thumbnail.data,
-        alt: `Screenshot`,
-      });
+      const imgEl = this._dom.createChildOf(frameEl, 'img', 'lh-filmstrip__thumbnail');
+      imgEl.src = thumbnail.data;
+      imgEl.alt = `Screenshot`;
     }
     return filmstripEl;
   }
diff --git a/report/renderer/dom.js b/report/renderer/dom.js
index 695ba0119..8b3894300 100644
--- a/report/renderer/dom.js
+++ b/report/renderer/dom.js
@@ -36,22 +36,13 @@ export class DOM {
    * @template {string} T
    * @param {T} name
    * @param {string=} className
-   * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
-   *     Note: if an attribute key has an undefined value, this method does not
-   *     set the attribute on the node.
    * @return {HTMLElementByTagName[T]}
    */
-  createElement(name, className, attrs = {}) {
+  createElement(name, className) {
     const element = this._document.createElement(name);
     if (className) {
       element.className = className;
     }
-    Object.keys(attrs).forEach(key => {
-      const value = attrs[key];
-      if (typeof value !== 'undefined') {
-        element.setAttribute(key, value);
-      }
-    });
     return element;
   }
 
@@ -59,22 +50,13 @@ export class DOM {
    * @param {string} namespaceURI
    * @param {string} name
    * @param {string=} className
-   * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
-   *     Note: if an attribute key has an undefined value, this method does not
-   *     set the attribute on the node.
    * @return {Element}
    */
-  createElementNS(namespaceURI, name, className, attrs = {}) {
+  createElementNS(namespaceURI, name, className) {
     const element = this._document.createElementNS(namespaceURI, name);
     if (className) {
       element.className = className;
     }
-    Object.keys(attrs).forEach(key => {
-      const value = attrs[key];
-      if (typeof value !== 'undefined') {
-        element.setAttribute(key, value);
-      }
-    });
     return element;
   }
 
@@ -90,13 +72,10 @@ export class DOM {
    * @param {Element} parentElem
    * @param {T} elementName
    * @param {string=} className
-   * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
-   *     Note: if an attribute key has an undefined value, this method does not
-   *     set the attribute on the node.
    * @return {HTMLElementByTagName[T]}
    */
-  createChildOf(parentElem, elementName, className, attrs) {
-    const element = this.createElement(elementName, className, attrs);
+  createChildOf(parentElem, elementName, className) {
+    const element = this.createElement(elementName, className);
     parentElem.appendChild(element);
     return element;
   }
@@ -161,13 +140,50 @@ export class DOM {
       a.rel = 'noopener';
       a.target = '_blank';
       a.textContent = segment.text;
-      a.href = url.href;
+      this.safelySetHref(a, url.href);
       element.appendChild(a);
     }
 
     return element;
   }
 
+  /**
+   * Set link href, but safely, preventing `javascript:` protocol, etc.
+   * @see https://github.com/google/safevalues/
+   * @param {HTMLAnchorElement} elem
+   * @param {string} url
+   */
+  safelySetHref(elem, url) {
+    // In-page anchor links are safe.
+    if (url.startsWith('#')) {
+      elem.href = url;
+      return;
+    }
+
+    const allowedProtocols = ['https:', 'http:'];
+    let parsed;
+    try {
+      parsed = new URL(url);
+    } catch (_) {}
+
+    if (parsed && allowedProtocols.includes(parsed.protocol)) {
+      elem.href = parsed.href;
+    }
+  }
+
+  /**
+   * Only create blob URLs for JSON & HTML
+   * @param {HTMLAnchorElement} elem
+   * @param {Blob} blob
+   */
+  safelySetBlobHref(elem, blob) {
+    if (blob.type !== 'text/html' && blob.type !== 'application/json') {
+      throw new Error('Unsupported blob type');
+    }
+    const href = URL.createObjectURL(blob);
+    elem.href = href;
+  }
+
   /**
    * @param {string} markdownText
    * @return {Element}
diff --git a/report/renderer/element-screenshot-renderer.js b/report/renderer/element-screenshot-renderer.js
index cd1507af0..ed151d92b 100644
--- a/report/renderer/element-screenshot-renderer.js
+++ b/report/renderer/element-screenshot-renderer.js
@@ -121,8 +121,9 @@ export class ElementScreenshotRenderer {
       `${right},${top} 1,${top}       1,${bottom}       ${right},${bottom}`,
     ];
     for (const points of polygonsPoints) {
-      clipPathEl.append(dom.createElementNS(
-        'http://www.w3.org/2000/svg', 'polygon', undefined, {points}));
+      const pointEl = dom.createElementNS('http://www.w3.org/2000/svg', 'polygon');
+      pointEl.setAttribute('points', points);
+      clipPathEl.append(pointEl);
     }
   }
 
diff --git a/report/renderer/performance-category-renderer.js b/report/renderer/performance-category-renderer.js
index 7130dfb2d..cfa372e7f 100644
--- a/report/renderer/performance-category-renderer.js
+++ b/report/renderer/performance-category-renderer.js
@@ -194,7 +194,7 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
     const calculatorLink = this.dom.createChildOf(estValuesEl, 'a', 'lh-calclink');
     calculatorLink.target = '_blank';
     calculatorLink.textContent = strings.calculatorLink;
-    calculatorLink.href = this._getScoringCalculatorHref(category.auditRefs);
+    this.dom.safelySetHref(calculatorLink, this._getScoringCalculatorHref(category.auditRefs));
 
 
     metricAuditsEl.classList.add('lh-audit-group--metrics');
@@ -313,16 +313,14 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
     ]);
     for (const metric of filterChoices) {
       const elemId = `metric-${metric.acronym}`;
-      const radioEl = this.dom.createChildOf(metricFilterEl, 'input', 'lh-metricfilter__radio', {
-        type: 'radio',
-        name: 'metricsfilter',
-        id: elemId,
-      });
-
-      const labelEl = this.dom.createChildOf(metricFilterEl, 'label', 'lh-metricfilter__label', {
-        for: elemId,
-        title: metric.result && metric.result.title,
-      });
+      const radioEl = this.dom.createChildOf(metricFilterEl, 'input', 'lh-metricfilter__radio');
+      radioEl.type = 'radio';
+      radioEl.name = 'metricsfilter';
+      radioEl.id = elemId;
+
+      const labelEl = this.dom.createChildOf(metricFilterEl, 'label', 'lh-metricfilter__label');
+      labelEl.htmlFor = elemId;
+      labelEl.title = metric.result && metric.result.title;
       labelEl.textContent = metric.acronym || metric.id;
 
       if (metric.acronym === 'All') {
diff --git a/report/renderer/pwa-category-renderer.js b/report/renderer/pwa-category-renderer.js
index b66f0833d..0012eecb6 100644
--- a/report/renderer/pwa-category-renderer.js
+++ b/report/renderer/pwa-category-renderer.js
@@ -60,7 +60,7 @@ export class PwaCategoryRenderer extends CategoryRenderer {
 
     const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge--pwa', this.templateContext);
     const wrapper = this.dom.find('a.lh-gauge--pwa__wrapper', tmpl);
-    wrapper.href = `#${category.id}`;
+    this.dom.safelySetHref(wrapper, `#${category.id}`);
 
     // Correct IDs in case multiple instances end up in the page.
     const svgRoot = tmpl.querySelector('svg');
diff --git a/report/renderer/report-renderer.js b/report/renderer/report-renderer.js
index 7a8523edf..04f82a24c 100644
--- a/report/renderer/report-renderer.js
+++ b/report/renderer/report-renderer.js
@@ -72,8 +72,9 @@ export class ReportRenderer {
   _renderReportTopbar(report) {
     const el = this._dom.cloneTemplate('#tmpl-lh-topbar', this._templateContext);
     const metadataUrl = this._dom.find('a.lh-topbar__url', el);
-    metadataUrl.href = metadataUrl.textContent = report.finalUrl;
+    metadataUrl.textContent = report.finalUrl;
     metadataUrl.title = report.finalUrl;
+    this._dom.safelySetHref(metadataUrl, report.finalUrl);
     return el;
   }
 
diff --git a/report/renderer/report-ui-features.js b/report/renderer/report-ui-features.js
index 612cd2be6..104b2444f 100644
--- a/report/renderer/report-ui-features.js
+++ b/report/renderer/report-ui-features.js
@@ -702,17 +702,16 @@ export class ReportUIFeatures {
     });
 
     const ext = blob.type.match('json') ? '.json' : '.html';
-    const href = URL.createObjectURL(blob);
 
     const a = this._dom.createElement('a');
     a.download = `${filename}${ext}`;
-    a.href = href;
+    this._dom.safelySetBlobHref(a, blob);
     this._document.body.appendChild(a); // Firefox requires anchor to be in the DOM.
     a.click();
 
     // cleanup.
     this._document.body.removeChild(a);
-    setTimeout(_ => URL.revokeObjectURL(href), 500);
+    setTimeout(_ => URL.revokeObjectURL(a.href), 500);
   }
 
   /**
diff --git a/types/config.d.ts b/types/config.d.ts
index d5038979c..98bcfa764 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -56,6 +56,16 @@ declare global {
         groups: Record<string, Group> | null;
       }
 
+      /**
+       * Additional information about the context in which a Fraggle Rock config should be interpreted.
+       * This information is typically set by the CLI or other channel integrations.
+       */
+      export interface FRContext {
+        gatherMode?: LH.Gatherer.GatherMode;
+        configPath?: string;
+        settingsOverrides?: LH.SharedFlagsSettings;
+      }
+
       interface SharedPassNavigationJson {
         /**
          * Controls the behavior when the navigation fails to complete (due to server error, no FCP, etc).
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 86c13994f..bb195a163 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -257,6 +257,8 @@ declare global {
       quiet: boolean;
       /** A flag to print the normalized config for the given config and options, then exit. */
       printConfig: boolean;
+      /** Use the new Fraggle Rock navigation runner to gather CLI results. */
+      fraggleRock: boolean;
       /** Path to the file where precomputed lantern data should be read from. */
       precomputedLanternDataPath?: string;
       /** Path to the file where precomputed lantern data should be written to. */


Test patch:
diff --git a/lighthouse-cli/test/cli/bin-test.js b/lighthouse-cli/test/cli/bin-test.js
index 219c46083..4df8ac052 100644
--- a/lighthouse-cli/test/cli/bin-test.js
+++ b/lighthouse-cli/test/cli/bin-test.js
@@ -52,6 +52,7 @@ beforeEach(() => {
     view: false,
     verbose: false,
     quiet: false,
+    fraggleRock: false,
     port: 0,
     hostname: '',
     // Command modes
diff --git a/lighthouse-core/test/audits/csp-xss-test.js b/lighthouse-core/test/audits/csp-xss-test.js
index 957a97d19..0cc3c66d4 100644
--- a/lighthouse-core/test/audits/csp-xss-test.js
+++ b/lighthouse-core/test/audits/csp-xss-test.js
@@ -89,6 +89,7 @@ it('audit basic header', async () => {
     },
   };
   const results = await CspXss.audit(artifacts, {computedCache: new Map()});
+  expect(results.notApplicable).toBeFalsy();
   expect(results.details.items).toMatchObject(
     [
       {
@@ -117,6 +118,28 @@ it('audit basic header', async () => {
   );
 });
 
+it('marked N/A if no warnings found', async () => {
+  const artifacts = {
+    URL: 'https://example.com',
+    MetaElements: [],
+    devtoolsLogs: {
+      defaultPass: networkRecordsToDevtoolsLog([
+        {
+          url: 'https://example.com',
+          responseHeaders: [
+            {
+              name: 'Content-Security-Policy',
+              value: `script-src 'none'; object-src 'none'; base-uri 'none'; report-uri https://csp.example.com`},
+          ],
+        },
+      ]),
+    },
+  };
+  const results = await CspXss.audit(artifacts, {computedCache: new Map()});
+  expect(results.details.items).toHaveLength(0);
+  expect(results.notApplicable).toBeTruthy();
+});
+
 describe('getRawCsps', () => {
   it('basic case', async () => {
     const artifacts = {
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index e1ea99ef1..696febf8d 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -130,9 +130,9 @@ describe('Fraggle Rock API', () => {
         notApplicableAudits,
       } = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`56`);
+      expect(auditResults.length).toMatchInlineSnapshot(`59`);
 
-      expect(notApplicableAudits.length).toMatchInlineSnapshot(`6`);
+      expect(notApplicableAudits.length).toMatchInlineSnapshot(`7`);
       expect(notApplicableAudits.map(audit => audit.id)).not.toContain('server-response-time');
 
       expect(erroredAudits).toHaveLength(0);
@@ -172,13 +172,13 @@ describe('Fraggle Rock API', () => {
       if (!result) throw new Error('Lighthouse failed to produce a result');
 
       const {auditResults, erroredAudits, notApplicableAudits} = getAuditsBreakdown(result.lhr);
-      expect(auditResults.length).toMatchInlineSnapshot(`56`);
+      expect(auditResults.length).toMatchInlineSnapshot(`59`);
 
-      expect(notApplicableAudits.length).toMatchInlineSnapshot(`8`);
+      expect(notApplicableAudits.length).toMatchInlineSnapshot(`9`);
       expect(notApplicableAudits.map(audit => audit.id)).toContain('server-response-time');
 
       // TODO(FR-COMPAT): Reduce this number by handling the error, making N/A, or removing timespan support.
-      expect(erroredAudits.length).toMatchInlineSnapshot(`12`);
+      expect(erroredAudits.length).toMatchInlineSnapshot(`14`);
     });
   });
 
diff --git a/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js b/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
index 4a4ef5f54..5cb204f34 100644
--- a/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
@@ -7,15 +7,21 @@
 
 /* eslint-env jest */
 
-const {createMockDriver, mockDriverSubmodules} = require('./mock-driver.js');
+const {createMockDriver, mockDriverSubmodules, mockRunnerModule} = require('./mock-driver.js');
 const mocks = mockDriverSubmodules();
-const runner = require('../../../fraggle-rock/gather/navigation-runner.js');
 const {initializeConfig} = require('../../../fraggle-rock/config/config.js');
 const {defaultNavigationConfig} = require('../../../config/constants.js');
 const LighthouseError = require('../../../lib/lh-error.js');
 const DevtoolsLogGatherer = require('../../../gather/gatherers/devtools-log.js');
 const toDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
+// Establish the mocks before we require our file under test.
+let mockRunnerRun = jest.fn();
+
+jest.mock('../../../runner.js', () => mockRunnerModule(() => mockRunnerRun));
+
+const runner = require('../../../fraggle-rock/gather/navigation-runner.js');
+
 /** @typedef {{meta: LH.Gatherer.GathererMeta<'Accessibility'>, getArtifact: jest.Mock<any, any>, startInstrumentation:jest.Mock<any, any>, stopInstrumentation: jest.Mock<any, any>, startSensitiveInstrumentation:jest.Mock<any, any>, stopSensitiveInstrumentation: jest.Mock<any, any>}} MockGatherer */
 
 describe('NavigationRunner', () => {
@@ -79,6 +85,7 @@ describe('NavigationRunner', () => {
 
   beforeEach(() => {
     requestedUrl = 'http://example.com';
+    mockRunnerRun = jest.fn();
     config = initializeConfig(undefined, {gatherMode: 'navigation'}).config;
     navigation = createNavigation().navigation;
     computedCache = new Map();
@@ -381,4 +388,27 @@ describe('NavigationRunner', () => {
       expect(mocks.storageMock.clearDataForOrigin).not.toHaveBeenCalled();
     });
   });
+
+  describe('navigation', () => {
+    it('should initialize config', async () => {
+      const settingsOverrides = {
+        formFactor: /** @type {'desktop'} */ ('desktop'),
+        maxWaitForLoad: 1234,
+        screenEmulation: {mobile: false},
+      };
+
+      const configContext = {settingsOverrides};
+      await runner.navigation({
+        url: 'http://example.com',
+        page: mockDriver._page.asPage(),
+        configContext,
+      });
+
+      expect(mockRunnerRun.mock.calls[0][1]).toMatchObject({
+        config: {
+          settings: settingsOverrides,
+        },
+      });
+    });
+  });
 });
diff --git a/lighthouse-core/test/fraggle-rock/gather/snapshot-runner-test.js b/lighthouse-core/test/fraggle-rock/gather/snapshot-runner-test.js
index 8c9f7afa0..aedba45df 100644
--- a/lighthouse-core/test/fraggle-rock/gather/snapshot-runner-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/snapshot-runner-test.js
@@ -89,6 +89,24 @@ describe('Snapshot Runner', () => {
     expect(gathererB.getArtifact).toHaveBeenCalled();
   });
 
+
+  it('should use configContext', async () => {
+    const settingsOverrides = {
+      formFactor: /** @type {'desktop'} */ ('desktop'),
+      maxWaitForLoad: 1234,
+      screenEmulation: {mobile: false},
+    };
+
+    const configContext = {settingsOverrides};
+    await snapshot({page, config, configContext});
+
+    expect(mockRunnerRun.mock.calls[0][1]).toMatchObject({
+      config: {
+        settings: settingsOverrides,
+      },
+    });
+  });
+
   it('should not invoke instrumentation methods', async () => {
     await snapshot({page, config});
     await mockRunnerRun.mock.calls[0][0]();
diff --git a/lighthouse-core/test/fraggle-rock/gather/timespan-runner-test.js b/lighthouse-core/test/fraggle-rock/gather/timespan-runner-test.js
index 8706dd94d..3728bf8d7 100644
--- a/lighthouse-core/test/fraggle-rock/gather/timespan-runner-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/timespan-runner-test.js
@@ -98,6 +98,24 @@ describe('Timespan Runner', () => {
     });
   });
 
+  it('should use configContext', async () => {
+    const settingsOverrides = {
+      formFactor: /** @type {'desktop'} */ ('desktop'),
+      maxWaitForLoad: 1234,
+      screenEmulation: {mobile: false},
+    };
+
+    const configContext = {settingsOverrides};
+    const timespan = await startTimespan({page, config, configContext});
+    await timespan.endTimespan();
+
+    expect(mockRunnerRun.mock.calls[0][1]).toMatchObject({
+      config: {
+        settings: settingsOverrides,
+      },
+    });
+  });
+
   it('should invoke stop instrumentation', async () => {
     const timespan = await startTimespan({page, config});
     await timespan.endTimespan();
diff --git a/lighthouse-core/test/lib/page-functions-test.js b/lighthouse-core/test/lib/page-functions-test.js
index fe8a09321..37daff877 100644
--- a/lighthouse-core/test/lib/page-functions-test.js
+++ b/lighthouse-core/test/lib/page-functions-test.js
@@ -15,33 +15,32 @@ let assert;
 let jsdom;
 /** @type {import('../../lib/page-functions.js')} */
 let pageFunctions;
-/** @type {import('../../../report/renderer/dom.js').DOM} */
-let DOM;
 
 /* eslint-env jest */
 
+/* global document */
+
 describe('Page Functions', () => {
   const url = 'http://www.example.com';
-  let dom;
 
   beforeAll(async () => {
     assert = (await import('assert')).strict;
     jsdom = await import('jsdom');
     pageFunctions = (await import('../../lib/page-functions.js')).default;
-    DOM = (await import('../../../report/renderer/dom.js')).DOM;
 
     const {document, ShadowRoot, Node, HTMLElement} = new jsdom.JSDOM('', {url}).window;
     global.ShadowRoot = ShadowRoot;
     global.Node = Node;
     global.HTMLElement = HTMLElement;
+    global.document = document;
     global.window = {};
-    dom = new DOM(document);
   });
 
   afterAll(() => {
     global.ShadowRoot = undefined;
     global.Node = undefined;
     global.window = undefined;
+    global.document = undefined;
   });
 
   describe('wrapRuntimeEvalErrorInBrowser()', () => {
@@ -85,71 +84,89 @@ describe('Page Functions', () => {
 
   describe('get outer HTML snippets', () => {
     it('gets full HTML snippet', () => {
-      assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {id: '1', style: 'style'})), '<div id="1" style="style">');
+      const elem = document.createElement('div');
+      elem.id = '1';
+      elem.style = 'width: 1px;';
+      assert.equal(pageFunctions.getOuterHTMLSnippet(elem), '<div id="1" style="width: 1px;">');
     });
 
     it('replaces img.src with img.currentSrc', () => {
-      const el = dom.createElement('img', '', {id: '1', src: 'no'});
+      const el = document.createElement('img');
+      el.id = '1';
+      el.src = 'no';
       Object.defineProperty(el, 'currentSrc', {value: 'yes'});
       assert.equal(pageFunctions.getOuterHTMLSnippet(el), '<img id="1" src="yes">');
     });
 
     it('does not replace img.src with img.currentSrc if resolve to same URL', () => {
-      const el = dom.createElement('img', '', {id: '1', src: './a.png'});
+      const el = document.createElement('img');
+      el.id = '1';
+      el.src = './a.png';
       Object.defineProperty(el, 'currentSrc', {value: `${url}/a.png`});
       assert.equal(pageFunctions.getOuterHTMLSnippet(el), '<img id="1" src="./a.png">');
     });
 
     it('removes a specific attribute', () => {
-      assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {id: '1', style: 'style'}), ['style']), '<div id="1">');
+      const elem = document.createElement('div');
+      elem.id = '1';
+      elem.style = 'width: 1px;';
+      assert.equal(pageFunctions.getOuterHTMLSnippet(elem, ['style']), '<div id="1">');
     });
 
     it('removes multiple attributes', () => {
-      assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {'id': '1', 'style': 'style', 'aria-label': 'label'}),
-        ['style', 'aria-label']
-      ), '<div id="1">');
+      const elem = document.createElement('div');
+      elem.id = '1';
+      elem.style = 'width: 1px;';
+      elem.setAttribute('aria-label', 'label');
+      assert.equal(
+        pageFunctions.getOuterHTMLSnippet(elem, ['style', 'aria-label']),
+        '<div id="1">');
     });
 
     it('should handle dom nodes that cannot be cloned', () => {
-      const element = dom.createElement('div');
+      const element = document.createElement('div');
       element.cloneNode = () => {
         throw new Error('oops!');
       };
       assert.equal(pageFunctions.getOuterHTMLSnippet(element), '<div>');
     });
     it('ignores when attribute not found', () => {
+      const elem = document.createElement('div');
+      elem.id = '1';
+      elem.style = 'width: 1px;';
+      elem.setAttribute('aria-label', 'label');
       assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {'id': '1', 'style': 'style', 'aria-label': 'label'}),
+        elem,
         ['style-missing', 'aria-label-missing']
-      ), '<div id="1" style="style" aria-label="label">');
+      ), '<div id="1" style="width: 1px;" aria-label="label">');
     });
 
     it('works if attribute values contain line breaks', () => {
-      assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {style: 'style1\nstyle2'})), '<div style="style1\nstyle2">');
+      const elem = document.createElement('div');
+      elem.style = 'width: 1px;\nheight: 2px;';
+      assert.equal(pageFunctions.getOuterHTMLSnippet(elem),
+        '<div style="width: 1px; height: 2px;">');
     });
 
     it('truncates attribute values that are too long', () => {
-      const longClass = 'a'.repeat(200);
+      const elem = document.createElement('div');
+      elem.className = 'a'.repeat(200);
       const truncatedExpectation = 'a'.repeat(74) + '…';
-      assert.equal(pageFunctions.getOuterHTMLSnippet(
-        dom.createElement('div', '', {class: longClass})), `<div class="${truncatedExpectation}">`
-      );
+      assert.equal(
+        pageFunctions.getOuterHTMLSnippet(elem),
+        `<div class="${truncatedExpectation}">`);
     });
 
     it('removes attributes if the length of the attribute name + value is too long', () => {
       const longValue = 'a'.repeat(200);
       const truncatedValue = 'a'.repeat(74) + '…';
-      const element = dom.createElement('div', '', {
-        class: longValue,
-        id: longValue,
-        att1: 'shouldn\'t see this',
-        att2: 'shouldn\'t see this either',
-      });
-      const snippet = pageFunctions.getOuterHTMLSnippet(element, [], 150);
+      const elem = document.createElement('div');
+      elem.className = longValue;
+      elem.id = longValue;
+      elem.setAttribute('att1', 'shouldn\'t see this');
+      elem.setAttribute('att2', 'shouldn\'t see this either');
+
+      const snippet = pageFunctions.getOuterHTMLSnippet(elem, [], 150);
       assert.equal(snippet, `<div class="${truncatedValue}" id="${truncatedValue}" …>`
       );
     });
@@ -157,8 +174,11 @@ describe('Page Functions', () => {
 
   describe('getNodeSelector', () => {
     it('Uses IDs where available and otherwise falls back to classes', () => {
-      const parentEl = dom.createElement('div', '', {id: 'wrapper', class: 'dont-use-this'});
-      const childEl = dom.createElement('div', '', {class: 'child'});
+      const parentEl = document.createElement('div');
+      parentEl.id = 'wrapper';
+      parentEl.className = 'dont-use-this';
+      const childEl = document.createElement('div');
+      childEl.className = 'child';
       parentEl.appendChild(childEl);
       assert.equal(pageFunctions.getNodeSelector(childEl), 'div#wrapper > div.child');
     });
@@ -166,26 +186,27 @@ describe('Page Functions', () => {
 
   describe('getNodeLabel', () => {
     it('Returns innerText if element has visible text', () => {
-      const el = dom.createElement('div');
+      const el = document.createElement('div');
       el.innerText = 'Hello';
       assert.equal(pageFunctions.getNodeLabel(el), 'Hello');
     });
 
     it('Falls back to children and alt/aria-label if a title can\'t be determined', () => {
-      const el = dom.createElement('div');
-      const childEl = dom.createElement('div', '', {'aria-label': 'Something'});
+      const el = document.createElement('div');
+      const childEl = document.createElement('div');
+      childEl.setAttribute('aria-label', 'Something');
       el.appendChild(childEl);
       assert.equal(pageFunctions.getNodeLabel(el), 'Something');
     });
 
     it('Truncates long text', () => {
-      const el = dom.createElement('div');
+      const el = document.createElement('div');
       el.setAttribute('alt', Array(100).fill('a').join(''));
       assert.equal(pageFunctions.getNodeLabel(el).length, 80);
     });
 
     it('Truncates long text containing unicode surrogate pairs', () => {
-      const el = dom.createElement('div');
+      const el = document.createElement('div');
       // `getNodeLabel` truncates to 80 characters internally.
       // We want to test a unicode character on the boundary.
       el.innerText = Array(78).fill('a').join('') + '💡💡💡';
@@ -193,8 +214,8 @@ describe('Page Functions', () => {
     });
 
     it('Returns null if there is no better label', () => {
-      const el = dom.createElement('div');
-      const childEl = dom.createElement('span');
+      const el = document.createElement('div');
+      const childEl = document.createElement('span');
       el.appendChild(childEl);
       assert.equal(pageFunctions.getNodeLabel(el), null);
     });
@@ -202,7 +223,7 @@ describe('Page Functions', () => {
 
   describe('getNodePath', () => {
     it('returns basic node path', () => {
-      const el = dom.createElement('div');
+      const el = document.createElement('div');
       el.innerHTML = `
         <section>
           <span>Sup</span>
@@ -215,11 +236,11 @@ describe('Page Functions', () => {
     });
 
     it('returns node path through shadow root', () => {
-      const el = dom.createElement('div');
-      const main = el.appendChild(dom.createElement('main'));
+      const el = document.createElement('div');
+      const main = el.appendChild(document.createElement('main'));
       const shadowRoot = main.attachShadow({mode: 'open'});
-      const sectionEl = dom.createElement('section');
-      const img = dom.createElement('img');
+      const sectionEl = document.createElement('section');
+      const img = document.createElement('img');
       img.src = '#';
       sectionEl.append(img);
       shadowRoot.append(sectionEl);
@@ -230,8 +251,12 @@ describe('Page Functions', () => {
 
   describe('getNodeDetails', () => {
     it('Returns selector as fallback if nodeLabel equals html tag name', () => {
-      const el = dom.createElement('div', '', {id: 'parent', class: 'parent-el'});
-      const childEl = dom.createElement('p', '', {id: 'child', class: 'child-el'});
+      const el = document.createElement('div');
+      el.id = 'parent';
+      el.className = 'parent-el';
+      const childEl = document.createElement('p');
+      childEl.id = 'child';
+      childEl.className = 'child-el';
       el.appendChild(childEl);
       const {nodeLabel} = pageFunctions.getNodeDetails(el);
       assert.equal(nodeLabel, 'div#parent');
diff --git a/report/test/renderer/dom-test.js b/report/test/renderer/dom-test.js
index 80aa5d7e3..aadf62802 100644
--- a/report/test/renderer/dom-test.js
+++ b/report/test/renderer/dom-test.js
@@ -6,6 +6,8 @@
 'use strict';
 
 import {strict as assert} from 'assert';
+import {jest} from '@jest/globals';
+
 import jsdom from 'jsdom';
 import reportAssets from '../../report-assets.js';
 import {DOM} from '../../renderer/dom.js';
@@ -16,12 +18,19 @@ import {I18n} from '../../renderer/i18n.js';
 
 describe('DOM', () => {
   let dom;
+  let window;
 
   beforeAll(() => {
     Util.i18n = new I18n('en', {...Util.UIStrings});
+    window = new jsdom.JSDOM(reportAssets.REPORT_TEMPLATES).window;
+
+    // Make a lame "polyfill" since JSDOM doesn't have createObjectURL: https://github.com/jsdom/jsdom/issues/1721
+    const lameCOURL = jest.fn(_ => `https://fake-origin/blahblah-blobid`);
+    if (!URL.createObjectURL) {
+      URL.createObjectURL = lameCOURL;
+    }
 
-    const {document} = new jsdom.JSDOM(reportAssets.REPORT_TEMPLATES).window;
-    dom = new DOM(document);
+    dom = new DOM(window.document);
     dom.setLighthouseChannel('someChannel');
   });
 
@@ -36,15 +45,6 @@ describe('DOM', () => {
       assert.equal(el.className, '');
       assert.equal(el.hasAttributes(), false);
     });
-
-    it('creates an element from parameters', () => {
-      const el = dom.createElement(
-          'div', 'class1 class2', {title: 'title attr', tabindex: 0});
-      assert.equal(el.localName, 'div');
-      assert.equal(el.className, 'class1 class2');
-      assert.equal(el.getAttribute('title'), 'title attr');
-      assert.equal(el.getAttribute('tabindex'), '0');
-    });
   });
 
   describe('cloneTemplate', () => {
@@ -153,4 +153,72 @@ describe('DOM', () => {
           '<code>more code</code>, and yet event <code>more</code>.');
     });
   });
+
+  describe('safelySetHref', () => {
+    it('sets href for safe destinations', () => {
+      [
+        'https://safe.com/',
+        'http://safe.com/',
+      ].forEach(url => {
+        const a = dom.createElement('a');
+        dom.safelySetHref(a, url);
+        expect(a.href).toEqual(url);
+      });
+    });
+
+    it('sets href for safe in-page named anchors', () => {
+      const a = dom.createElement('a');
+      dom.safelySetHref(a, '#footer');
+      expect(a.href).toEqual('about:blank#footer');
+    });
+
+    it('doesnt set href if destination is unsafe', () => {
+      [
+        'javascript:evil()',
+        'data:text/html;base64,abcdef',
+        'data:application/json;base64,abcdef',
+        'ftp://evil.com/',
+        'blob:http://example.com/ca6df701-9c67-49fd-a787',
+        'intent://example.com',
+        'filesystem:http://localhost/img.png',
+        'ws://evilchat.com/',
+        'wss://evilchat.com/',
+        'about:about',
+        'chrome://chrome-urls/',
+      ].forEach(url => {
+        const a = dom.createElement('a');
+        dom.safelySetHref(a, url);
+
+        expect(a.href).toEqual('');
+        expect(a.getAttribute('href')).toEqual(null);
+      });
+    });
+  });
+
+
+  describe('safelySetBlobHref', () => {
+    it('sets href for safe blob types', () => {
+      [
+        new window.Blob([JSON.stringify({test: 1234})], {type: 'application/json'}),
+        new window.Blob([`<html><h1>hello`], {type: 'text/html'}),
+      ].forEach(blob => {
+        const a = dom.createElement('a');
+        dom.safelySetBlobHref(a, blob);
+        expect(a.href).toEqual('https://fake-origin/blahblah-blobid');
+      });
+    });
+
+    it('throws with unsupported blob types', () => {
+      [
+        new window.Blob(['<xml>'], {type: 'image/svg+xml'}),
+        new window.Blob(['evilbinary'], {type: 'application/octet-stream'}),
+        new window.Blob(['fake']),
+      ].forEach(blob => {
+        const a = dom.createElement('a');
+        expect(() => {
+          dom.safelySetBlobHref(a, blob);
+        }).toThrowError(/Unsupported blob/);
+      });
+    });
+  });
 });
diff --git a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-view-trace-run.js b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-view-trace-run.js
index 5264bc578..ab01fd449 100644
--- a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-view-trace-run.js
+++ b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-view-trace-run.js
@@ -33,7 +33,7 @@
   });
   const viewTraceButton = LighthouseTestRunner.getResultsElement().querySelector('.lh-button--trace');
   TestRunner.addResult(`\nView Trace Button Text: "${viewTraceButton.textContent}"`);
-  TestRunner.addResult(`View Trace Button Title: "${UI.Tooltip.getContent(viewTraceButton)}"`);
+  TestRunner.addResult(`View Trace Button Title: "${viewTraceButton.title}"`);
   viewTraceButton.click();
   const viewShown = await waitForShowView;
   TestRunner.addResult(`\nShowing view: ${viewShown}`);