Repository: GoogleChrome/lighthouse
Pull request #6171 (2018-10-04 01:33:53)

tests: add networkRecord-to-devtoolsLog mocking utility
This came up while refactoring computed artifacts and I've separated it to keep the PRs simpler. Since we'll be requiring files normally, we can't just inject `requestNetworkRecords` into tests anymore (at least not as easily).

We have a lot of places that we want to pass in example/simplified `networkRecords`, however our core artifact for network activity is the `devtoolsLog`. This PR adds a simple test utility that will create a `devtoolsLog` that, when passed back into `NetworkRecorder`, will create `networkRecords` with all the same properties.

Very simple records can be passed in (good for tests! e.g. just `[{url: 'https://example.com}]`) and the rest of the needed properties will be filled in with reasonable-ish defaults.

I've gone through all the existing tests that were mocking `requestNetworkRecords: () => Promise.resolve(records)` to use a generated devtoolsLog instead.

benschwarz: Could this be repurposed to generate hars and other network analysis under simulation mode @patrickhulce? 
brendankenny: it could maybe be a start, but there's probably easier/more robust ways to go about it. This is definitely aimed at when you need to test the accessing of just a few properties, like the main resource by URL in a set of records and you don't care that that request started at timestamp 0 and was completed 1 µs later :)

Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/offscreen-images.js b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
index fca48e3ad..0bd360ddf 100644
--- a/lighthouse-core/audits/byte-efficiency/offscreen-images.js
+++ b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
@@ -165,7 +165,7 @@ class OffscreenImages extends ByteEfficiencyAudit {
    * @param {LH.Audit.Context} context
    * @return {Promise<ByteEfficiencyAudit.ByteEfficiencyProduct>}
    */
-  static audit_(artifacts, networkRecords, context) {
+  static async audit_(artifacts, networkRecords, context) {
     const images = artifacts.ImageUsage;
     const viewportDimensions = artifacts.ViewportDimensions;
     const trace = artifacts.traces[ByteEfficiencyAudit.DEFAULT_PASS];
@@ -195,29 +195,43 @@ class OffscreenImages extends ByteEfficiencyAudit {
     }, /** @type {Map<string, WasteResult>} */ (new Map()));
 
     const settings = context.settings;
-    return artifacts.requestInteractive({trace, devtoolsLog, settings}).then(interactive => {
-      const unfilteredResults = Array.from(resultsMap.values());
+
+    let items;
+    const unfilteredResults = Array.from(resultsMap.values());
+    // get the interactive time or fallback to getting the end of trace time
+    try {
+      const interactive = await artifacts.requestInteractive({trace, devtoolsLog, settings});
+
+      // use interactive to generate items
       const lanternInteractive = /** @type {LH.Artifacts.LanternMetric} */ (interactive);
       // Filter out images that were loaded after all CPU activity
-      const items = context.settings.throttlingMethod === 'simulate' ?
+      items = context.settings.throttlingMethod === 'simulate' ?
         OffscreenImages.filterLanternResults(unfilteredResults, lanternInteractive) :
         // @ts-ignore - .timestamp will exist if throttlingMethod isn't lantern
         OffscreenImages.filterObservedResults(unfilteredResults, interactive.timestamp);
+    } catch (err) {
+      // if the error is during a Lantern run, end of trace may also be inaccurate, so rethrow
+      if (context.settings.throttlingMethod === 'simulate') {
+        throw err;
+      }
+      // use end of trace as a substitute for finding interactive time
+      items = OffscreenImages.filterObservedResults(unfilteredResults,
+        await artifacts.requestTraceOfTab(trace).then(tot => tot.timestamps.traceEnd));
+    }
 
-      /** @type {LH.Result.Audit.OpportunityDetails['headings']} */
-      const headings = [
-        {key: 'url', valueType: 'thumbnail', label: ''},
-        {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
-        {key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)},
-        {key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)},
-      ];
-
-      return {
-        warnings,
-        items,
-        headings,
-      };
-    });
+    /** @type {LH.Result.Audit.OpportunityDetails['headings']} */
+    const headings = [
+      {key: 'url', valueType: 'thumbnail', label: ''},
+      {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
+      {key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)},
+      {key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)},
+    ];
+
+    return {
+      warnings,
+      items,
+      headings,
+    };
   }
 }
 
diff --git a/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js b/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
index eec3543ff..cfa7fdf4b 100644
--- a/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
+++ b/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
@@ -153,7 +153,7 @@ class NoVulnerableLibrariesAudit extends Audit {
           vulnCount,
           detectedLib: {
             text: lib.name + '@' + version,
-            url: `https://snyk.io/vuln/npm:${lib.npmPkgName}?lh=${version}`,
+            url: `https://snyk.io/vuln/npm:${lib.npmPkgName}?lh=${version}&utm_source=lighthouse&utm_medium=ref&utm_campaign=audit`,
             type: 'link',
           },
         });
diff --git a/lighthouse-extension/app/src/lighthouse-ext-background.js b/lighthouse-extension/app/src/lighthouse-ext-background.js
index 8556f3be2..f4b81ca51 100644
--- a/lighthouse-extension/app/src/lighthouse-ext-background.js
+++ b/lighthouse-extension/app/src/lighthouse-ext-background.js
@@ -10,14 +10,6 @@ const background = require('./lighthouse-background');
 
 const ExtensionProtocol = require('../../../lighthouse-core/gather/connections/extension');
 const log = require('lighthouse-logger');
-const assetSaver = require('../../../lighthouse-core/lib/asset-saver.js');
-const LHError = require('../../../lighthouse-core/lib/lh-error.js');
-
-/** @type {Record<'mobile'|'desktop', LH.Config.Json>} */
-const LR_PRESETS = {
-  mobile: require('../../../lighthouse-core/config/lr-mobile-config'),
-  desktop: require('../../../lighthouse-core/config/lr-desktop-config'),
-};
 
 /** @typedef {import('../../../lighthouse-core/gather/connections/connection.js')} Connection */
 
@@ -87,56 +79,6 @@ async function runLighthouseInExtension(flags, categoryIDs) {
   await new Promise(resolve => chrome.windows.create({url: blobURL}, resolve));
 }
 
-/**
- * Run lighthouse for connection and provide similar results as in CLI.
- * @param {Connection} connection
- * @param {string} url
- * @param {LH.Flags} flags Lighthouse flags, including `output`
- * @param {{lrDevice?: 'desktop'|'mobile', categoryIDs?: Array<string>, logAssets: boolean}} lrOpts Options coming from Lightrider
- * @return {Promise<string|Array<string>|void>}
- */
-async function runLighthouseInLR(connection, url, flags, {lrDevice, categoryIDs, logAssets}) {
-  // Certain fixes need to kick-in under LR, see https://github.com/GoogleChrome/lighthouse/issues/5839
-  global.isLightRider = true;
-
-  // disableStorageReset because it causes render server hang
-  flags.disableStorageReset = true;
-  flags.logLevel = flags.logLevel || 'info';
-  const config = lrDevice === 'desktop' ? LR_PRESETS.desktop : LR_PRESETS.mobile;
-  if (categoryIDs) {
-    config.settings = config.settings || {};
-    config.settings.onlyCategories = categoryIDs;
-  }
-
-  try {
-    const results = await lighthouse(url, flags, config, connection);
-    if (!results) return;
-
-    if (logAssets) {
-      await assetSaver.logAssets(results.artifacts, results.lhr.audits);
-    }
-    return results.report;
-  } catch (err) {
-    // If an error ruined the entire lighthouse run, attempt to return a meaningful error.
-    let runtimeError;
-    if (!(err instanceof LHError) || !err.lhrRuntimeError) {
-      runtimeError = {
-        code: LHError.UNKNOWN_ERROR,
-        message: `Unknown error encountered with message '${err.message}'`,
-      };
-    } else {
-      runtimeError = {
-        code: err.code,
-        message: err.friendlyMessage ?
-            `${err.friendlyMessage} (${err.message})` :
-            err.message,
-      };
-    }
-
-    return JSON.stringify({runtimeError}, null, 2);
-  }
-}
-
 /**
  * @param {string} reportHtml
  * @return {string} Blob URL of the report (or error page) HTML
@@ -241,7 +183,6 @@ if (typeof module !== 'undefined' && module.exports) {
   // Export for importing types into popup.js, require()ing into unit tests.
   module.exports = {
     runLighthouseInExtension,
-    runLighthouseInLR,
     getDefaultCategories: background.getDefaultCategories,
     isRunning,
     listenForStatus,
@@ -255,8 +196,6 @@ if (typeof window !== 'undefined') {
   // @ts-ignore
   window.runLighthouseInExtension = runLighthouseInExtension;
   // @ts-ignore
-  window.runLighthouseInLR = runLighthouseInLR;
-  // @ts-ignore
   window.getDefaultCategories = background.getDefaultCategories;
   // @ts-ignore
   window.isRunning = isRunning;
diff --git a/lighthouse-extension/app/src/lighthouse-lr-background.js b/lighthouse-extension/app/src/lighthouse-lr-background.js
new file mode 100644
index 000000000..426d42428
--- /dev/null
+++ b/lighthouse-extension/app/src/lighthouse-lr-background.js
@@ -0,0 +1,82 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const lighthouse = require('../../../lighthouse-core/index');
+
+const assetSaver = require('../../../lighthouse-core/lib/asset-saver.js');
+const LHError = require('../../../lighthouse-core/lib/lh-error.js');
+
+/** @type {Record<'mobile'|'desktop', LH.Config.Json>} */
+const LR_PRESETS = {
+  mobile: require('../../../lighthouse-core/config/lr-mobile-config'),
+  desktop: require('../../../lighthouse-core/config/lr-desktop-config'),
+};
+
+/** @typedef {import('../../../lighthouse-core/gather/connections/connection.js')} Connection */
+
+/**
+ * Run lighthouse for connection and provide similar results as in CLI.
+ * @param {Connection} connection
+ * @param {string} url
+ * @param {LH.Flags} flags Lighthouse flags, including `output`
+ * @param {{lrDevice?: 'desktop'|'mobile', categoryIDs?: Array<string>, logAssets: boolean}} lrOpts Options coming from Lightrider
+ * @return {Promise<string|Array<string>|void>}
+ */
+async function runLighthouseInLR(connection, url, flags, {lrDevice, categoryIDs, logAssets}) {
+  // Certain fixes need to kick-in under LR, see https://github.com/GoogleChrome/lighthouse/issues/5839
+  global.isLightRider = true;
+
+  // disableStorageReset because it causes render server hang
+  flags.disableStorageReset = true;
+  flags.logLevel = flags.logLevel || 'info';
+  const config = lrDevice === 'desktop' ? LR_PRESETS.desktop : LR_PRESETS.mobile;
+  if (categoryIDs) {
+    config.settings = config.settings || {};
+    config.settings.onlyCategories = categoryIDs;
+  }
+
+  try {
+    const results = await lighthouse(url, flags, config, connection);
+    if (!results) return;
+
+    if (logAssets) {
+      await assetSaver.logAssets(results.artifacts, results.lhr.audits);
+    }
+    return results.report;
+  } catch (err) {
+    // If an error ruined the entire lighthouse run, attempt to return a meaningful error.
+    let runtimeError;
+    if (!(err instanceof LHError) || !err.lhrRuntimeError) {
+      runtimeError = {
+        code: LHError.UNKNOWN_ERROR,
+        message: `Unknown error encountered with message '${err.message}'`,
+      };
+    } else {
+      runtimeError = {
+        code: err.code,
+        message: err.friendlyMessage ?
+            `${err.friendlyMessage} (${err.message})` :
+            err.message,
+      };
+    }
+
+    return JSON.stringify({runtimeError}, null, 2);
+  }
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+  // Export for importing types into popup.js, require()ing into unit tests.
+  module.exports = {
+    runLighthouseInLR,
+  };
+}
+
+// Expose on window for extension, other browser-residing consumers of file.
+if (typeof window !== 'undefined') {
+  // @ts-ignore
+  window.runLighthouseInLR = runLighthouseInLR;
+}
diff --git a/lighthouse-extension/gulpfile.js b/lighthouse-extension/gulpfile.js
index 3e71daf2e..de0249793 100644
--- a/lighthouse-extension/gulpfile.js
+++ b/lighthouse-extension/gulpfile.js
@@ -34,6 +34,13 @@ const pkg = require('../package.json');
 
 const distDir = 'dist';
 
+// list of all consumers we build for (easier to understand which file is used for which)
+const CONSUMERS = {
+  DEVTOOLS: 'lighthouse-background.js',
+  EXTENSION: 'lighthouse-ext-background.js',
+  LIGHTRIDER: 'lighthouse-lr-background.js',
+};
+
 const VERSION = pkg.version;
 const COMMIT_HASH = require('child_process')
   .execSync('git rev-parse HEAD')
@@ -50,6 +57,12 @@ const gatherers = LighthouseRunner.getGathererList()
 const computedArtifacts = LighthouseRunner.getComputedGathererList()
     .map(f => '../lighthouse-core/gather/computed/' + f.replace(/\.js$/, ''));
 
+const locales = fs.readdirSync('../lighthouse-core/lib/i18n/locales/')
+    .map(f => require.resolve(`../lighthouse-core/lib/i18n/locales/${f}`));
+
+const isDevtools = file => file.endsWith(CONSUMERS.DEVTOOLS);
+const isExtension = file => file.endsWith(CONSUMERS.EXTENSION);
+
 gulp.task('extras', () => {
   return gulp.src([
     'app/*.*',
@@ -96,7 +109,7 @@ gulp.task('chromeManifest', () => {
   const manifestOpts = {
     buildnumber: false,
     background: {
-      target: 'scripts/lighthouse-ext-background.js',
+      target: `scripts/${CONSUMERS.EXTENSION}`,
     },
   };
   return gulp.src('app/manifest.json')
@@ -114,12 +127,10 @@ function applyBrowserifyTransforms(bundle) {
 }
 
 gulp.task('browserify-lighthouse', () => {
-  return gulp.src([
-    'app/src/lighthouse-background.js',
-    'app/src/lighthouse-ext-background.js',
-  ], {read: false})
+  const consumerSources = Object.values(CONSUMERS).map(consumer => `app/src/${consumer}`);
+  return gulp.src(consumerSources, {read: false})
     .pipe(tap(file => {
-      let bundle = browserify(file.path); // , {debug: true}); // for sourcemaps
+      let bundle = browserify(file.path, {debug: true}); // for sourcemaps
       bundle = applyBrowserifyTransforms(bundle);
 
       // scripts will need some additional transforms, ignores and requires…
@@ -135,10 +146,14 @@ gulp.task('browserify-lighthouse', () => {
       bundle.ignore(require.resolve('../lighthouse-core/gather/connections/cri.js'));
 
       // Prevent the DevTools background script from getting the stringified HTML.
-      if (/lighthouse-background/.test(file.path)) {
+      if (isDevtools(file.path)) {
         bundle.ignore(require.resolve('../lighthouse-core/report/html/html-report-assets.js'));
       }
 
+      if (isDevtools(file.path) || isExtension(file.path)) {
+        bundle.ignore(locales);
+      }
+
       // Expose the audits, gatherers, and computed artifacts so they can be dynamically loaded.
       const corePath = '../lighthouse-core/';
       const driverPath = `${corePath}gather/`;
@@ -197,9 +212,8 @@ gulp.task('compilejs', () => {
     // sourceMaps: 'both'
   };
 
-  return gulp.src([
-    'dist/scripts/lighthouse-background.js',
-    'dist/scripts/lighthouse-ext-background.js'])
+  const compiledSources = Object.values(CONSUMERS).map(consumer => `dist/scripts/${consumer}`);
+  return gulp.src(compiledSources)
     .pipe(tap(file => {
       const minified = babel.transform(file.contents.toString(), opts).code;
       file.contents = new Buffer(minified);


Test patch:
diff --git a/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js b/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
index 4999fa917..e101adeea 100644
--- a/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
@@ -10,6 +10,7 @@ const UnusedImages =
 const NetworkNode = require('../../../lib/dependency-graph/network-node');
 const CPUNode = require('../../../lib/dependency-graph/cpu-node');
 const assert = require('assert');
+const LHError = require('../../../lib/lh-error');
 
 /* eslint-env jest */
 function generateRecord(resourceSizeInKb, startTime = 0, mimeType = 'image/png') {
@@ -50,6 +51,20 @@ function generateInteractiveFunc(desiredTimeInSeconds) {
   });
 }
 
+function generateInteractiveFuncError() {
+  return () => Promise.reject(
+    new LHError(LHError.errors.NO_TTI_NETWORK_IDLE_PERIOD)
+  );
+}
+
+function generateTraceOfTab(desiredTimeInSeconds) {
+  return () => Promise.resolve({
+    timestamps: {
+      traceEnd: desiredTimeInSeconds * 1000000,
+    },
+  });
+}
+
 describe('OffscreenImages audit', () => {
   let context;
   const DEFAULT_DIMENSIONS = {innerWidth: 1920, innerHeight: 1080};
@@ -162,6 +177,38 @@ describe('OffscreenImages audit', () => {
     });
   });
 
+  it('disregards images loaded after Trace End when interactive throws error', () => {
+    return UnusedImages.audit_({
+      ViewportDimensions: DEFAULT_DIMENSIONS,
+      ImageUsage: [
+        // offscreen to the right
+        generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 3)),
+      ],
+      traces: {},
+      devtoolsLogs: {},
+      requestInteractive: generateInteractiveFuncError(),
+      requestTraceOfTab: generateTraceOfTab(2),
+    }, [], context, [], context).then(auditResult => {
+      assert.equal(auditResult.items.length, 0);
+    });
+  });
+
+  it('finds images loaded before Trace End when TTI when interactive throws error', () => {
+    return UnusedImages.audit_({
+      ViewportDimensions: DEFAULT_DIMENSIONS,
+      ImageUsage: [
+        // offscreen to the right
+        generateImage(generateSize(100, 100), [0, 2000], generateRecord(100)),
+      ],
+      traces: {},
+      devtoolsLogs: {},
+      requestInteractive: generateInteractiveFuncError(),
+      requestTraceOfTab: generateTraceOfTab(2),
+    }, [], context, [], context).then(auditResult => {
+      assert.equal(auditResult.items.length, 1);
+    });
+  });
+
   it('disregards images loaded after last long task (Lantern)', () => {
     context = {settings: {throttlingMethod: 'simulate'}};
     const recordA = {url: 'a', resourceSize: 100 * 1024, requestId: 'a'};
@@ -190,4 +237,75 @@ describe('OffscreenImages audit', () => {
       assert.equal(auditResult.items[0].url, 'a');
     });
   });
+
+  it('finds images loaded before last long task (Lantern)', () => {
+    context = {settings: {throttlingMethod: 'simulate'}};
+    const recordA = {url: 'a', resourceSize: 100 * 1024, requestId: 'a'};
+    const recordB = {url: 'b', resourceSize: 100 * 1024, requestId: 'b'};
+
+    const networkA = new NetworkNode(recordA);
+    const networkB = new NetworkNode(recordB);
+    const cpu = new CPUNode({}, []);
+    const timings = new Map([
+      [networkA, {startTime: 1000}],
+      [networkB, {startTime: 1500}],
+      [cpu, {startTime: 1975, endTime: 2025, duration: 50}],
+    ]);
+
+    return UnusedImages.audit_({
+      ViewportDimensions: DEFAULT_DIMENSIONS,
+      ImageUsage: [
+        generateImage(generateSize(0, 0), [0, 0], recordA, recordA.url),
+        generateImage(generateSize(200, 200), [3000, 0], recordB, recordB.url),
+      ],
+      traces: {},
+      devtoolsLogs: {},
+      requestInteractive: async () => ({pessimisticEstimate: {nodeTimings: timings}}),
+      requestTraceOfTab: generateTraceOfTab(2),
+    }, [], context, [], context).then(auditResult => {
+      assert.equal(auditResult.items.length, 2);
+      assert.equal(auditResult.items[0].url, 'a');
+      assert.equal(auditResult.items[1].url, 'b');
+    });
+  });
+
+  it('rethrow error when interactive throws error in Lantern', async () => {
+    context = {settings: {throttlingMethod: 'simulate'}};
+    try {
+      await UnusedImages.audit_({
+        ViewportDimensions: DEFAULT_DIMENSIONS,
+        ImageUsage: [
+          generateImage(generateSize(0, 0), [0, 0], generateRecord(100, 3), 'a'),
+          generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 4), 'b'),
+        ],
+        traces: {},
+        devtoolsLogs: {},
+        requestInteractive: generateInteractiveFuncError(),
+        requestTraceOfTab: generateTraceOfTab(2),
+      }, [], context, [], context);
+    } catch (err) {
+      return;
+    }
+    assert.ok(false);
+  });
+
+  it('finds images loaded before Trace End when interactive throws error (Lantern)', async () => {
+    context = {settings: {throttlingMethod: 'simulate'}};
+    try {
+      await UnusedImages.audit_({
+        ViewportDimensions: DEFAULT_DIMENSIONS,
+        ImageUsage: [
+          generateImage(generateSize(0, 0), [0, 0], generateRecord(100, 1), 'a'),
+          generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 4), 'b'),
+        ],
+        traces: {},
+        devtoolsLogs: {},
+        requestInteractive: generateInteractiveFuncError(),
+        requestTraceOfTab: generateTraceOfTab(2),
+      }, [], context, [], context);
+    } catch (err) {
+      return;
+    }
+    assert.ok(false);
+  });
 });
diff --git a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
index cf6f14f3a..71ff3c152 100644
--- a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
@@ -9,6 +9,8 @@ const TotalByteWeight = require('../../../audits/byte-efficiency/total-byte-weig
 const assert = require('assert');
 const URL = require('url').URL;
 const options = TotalByteWeight.defaultOptions;
+const Runner = require('../../../runner.js');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
@@ -32,10 +34,9 @@ function generateArtifacts(records) {
     records = records.map(args => generateRequest(...args));
   }
 
-  return {
-    devtoolsLogs: {defaultPass: []},
-    requestNetworkRecords: () => Promise.resolve(records),
-  };
+  return Object.assign(Runner.instantiateComputedArtifacts(), {
+    devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(records)},
+  });
 }
 
 describe('Total byte weight audit', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
index 4aa9bcd44..4ee98c8f4 100644
--- a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
@@ -7,6 +7,8 @@
 
 const UnusedCSSAudit = require('../../../audits/byte-efficiency/unused-css-rules.js');
 const assert = require('assert');
+const Runner = require('../../../runner.js');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
@@ -117,33 +119,32 @@ describe('Best Practices: unused css rules audit', () => {
   });
 
   describe('#audit', () => {
-    const devtoolsLogs = {defaultPass: []};
-    const requestNetworkRecords = () => {
-      return Promise.resolve([
-        {
-          url: 'file://a.css',
-          transferSize: 10 * 1024,
-          resourceType: 'Stylesheet',
-        },
-      ]);
-    };
+    const networkRecords = [
+      {
+        url: 'file://a.css',
+        transferSize: 10 * 1024,
+        resourceType: 'Stylesheet',
+      },
+    ];
+
+    function getArtifacts({CSSUsage}) {
+      return Object.assign(Runner.instantiateComputedArtifacts(), {
+        devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)},
+        URL: {finalUrl: ''},
+        CSSUsage,
+      });
+    }
 
     it('ignores missing stylesheets', () => {
-      return UnusedCSSAudit.audit_({
-        devtoolsLogs,
-        requestNetworkRecords,
-        URL: {finalUrl: ''},
+      return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [{styleSheetId: 'a', used: false}], stylesheets: []},
-      }).then(result => {
+      })).then(result => {
         assert.equal(result.items.length, 0);
       });
     });
 
     it('ignores stylesheets that are 100% used', () => {
-      return UnusedCSSAudit.audit_({
-        devtoolsLogs,
-        requestNetworkRecords,
-        URL: {finalUrl: ''},
+      return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
           {styleSheetId: 'a', used: true},
           {styleSheetId: 'a', used: true},
@@ -158,16 +159,13 @@ describe('Best Practices: unused css rules audit', () => {
             content: '.my.favorite.selector { rule: content; }',
           },
         ]},
-      }).then(result => {
+      })).then(result => {
         assert.equal(result.items.length, 0);
       });
     });
 
     it('fails when lots of rules are unused', () => {
-      return UnusedCSSAudit.audit_({
-        devtoolsLogs,
-        requestNetworkRecords,
-        URL: {finalUrl: ''},
+      return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
           {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 11}, // 44 * 1 / 4
           {styleSheetId: 'b', used: true, startOffset: 0, endOffset: 6000}, // 4000 * 3 / 2
@@ -185,7 +183,7 @@ describe('Best Practices: unused css rules audit', () => {
             content: `${generate('123', 450)}`, // will be filtered out
           },
         ]},
-      }).then(result => {
+      })).then(result => {
         assert.equal(result.items.length, 2);
         assert.equal(result.items[0].totalBytes, 10 * 1024);
         assert.equal(result.items[1].totalBytes, 6000);
@@ -195,10 +193,7 @@ describe('Best Practices: unused css rules audit', () => {
     });
 
     it('does not include empty or small sheets', () => {
-      return UnusedCSSAudit.audit_({
-        devtoolsLogs,
-        requestNetworkRecords,
-        URL: {finalUrl: ''},
+      return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
           {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 8000}, // 4000 * 3 / 2
           {styleSheetId: 'b', used: true, startOffset: 0, endOffset: 500}, // 500 * 3 / 3
@@ -224,7 +219,7 @@ describe('Best Practices: unused css rules audit', () => {
             content: '       ',
           },
         ]},
-      }).then(result => {
+      })).then(result => {
         assert.equal(result.items.length, 1);
         assert.equal(Math.floor(result.items[0].wastedPercent), 33);
       });
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index d49bae881..c80cc6c77 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -9,6 +9,8 @@ const CacheHeadersAudit = require('../../../audits/byte-efficiency/uses-long-cac
 const assert = require('assert');
 const NetworkRequest = require('../../../lib/network-request');
 const options = CacheHeadersAudit.defaultOptions;
+const Runner = require('../../../runner.js');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
@@ -28,20 +30,20 @@ function networkRecord(options = {}) {
 }
 
 describe('Cache headers audit', () => {
-  let artifacts;
-  let networkRecords;
+  function getArtifacts(networkRecords) {
+    const devtoolLogs = networkRecordsToDevtoolsLog(networkRecords);
 
-  beforeEach(() => {
-    artifacts = {
-      devtoolsLogs: {},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {
+        [CacheHeadersAudit.DEFAULT_PASS]: devtoolLogs,
+      },
       requestNetworkThroughput: () => Promise.resolve(1000),
-    };
-  });
+    });
+  }
 
   it('detects missing cache headers', () => {
-    networkRecords = [networkRecord()];
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    const networkRecords = [networkRecord()];
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 1);
       assert.equal(items[0].cacheLifetimeMs, 0);
@@ -51,14 +53,14 @@ describe('Cache headers audit', () => {
   });
 
   it('detects low value max-age headers', () => {
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {'cache-control': 'max-age=3600'}}), // an hour
       networkRecord({headers: {'cache-control': 'max-age=3600'}, transferSize: 100000}), // an hour
       networkRecord({headers: {'cache-control': 'max-age=86400'}}), // a day
       networkRecord({headers: {'cache-control': 'max-age=31536000'}}), // a year
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.details.items;
       assert.equal(items.length, 3);
       assert.equal(items[0].cacheLifetimeMs, 3600 * 1000);
@@ -76,14 +78,14 @@ describe('Cache headers audit', () => {
     const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString();
     const closeEnough = (actual, exp) => assert.ok(Math.abs(actual - exp) <= 1, 'invalid expires');
 
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {expires: expiresIn(86400 * 365)}}), // a year
       networkRecord({headers: {expires: expiresIn(86400 * 90)}}), // 3 months
       networkRecord({headers: {expires: expiresIn(86400)}}), // a day
       networkRecord({headers: {expires: expiresIn(3600)}}), // an hour
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 3);
       closeEnough(items[0].cacheLifetimeMs, 3600 * 1000);
@@ -98,7 +100,7 @@ describe('Cache headers audit', () => {
   it('respects expires/cache-control priority', () => {
     const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString();
 
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {
         'cache-control': 'must-revalidate,max-age=3600',
         'expires': expiresIn(86400),
@@ -109,7 +111,7 @@ describe('Cache headers audit', () => {
       }}),
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 2);
       assert.ok(Math.abs(items[0].cacheLifetimeMs - 3600 * 1000) <= 1, 'invalid expires parsing');
@@ -120,7 +122,7 @@ describe('Cache headers audit', () => {
   });
 
   it('respects multiple cache-control headers', () => {
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {
         'cache-control': 'max-age=31536000, public',
         'Cache-control': 'no-transform',
@@ -132,26 +134,26 @@ describe('Cache headers audit', () => {
       }}),
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 1);
     });
   });
 
   it('catches records with Etags', () => {
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {etag: 'md5hashhere'}}),
       networkRecord({headers: {'etag': 'md5hashhere', 'cache-control': 'max-age=60'}}),
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 2);
     });
   });
 
   it('ignores explicit no-cache policies', () => {
-    networkRecords = [
+    const networkRecords = [
       networkRecord({headers: {expires: '-1'}}),
       networkRecord({headers: {'cache-control': 'no-store'}}),
       networkRecord({headers: {'cache-control': 'no-cache'}}),
@@ -159,7 +161,7 @@ describe('Cache headers audit', () => {
       networkRecord({headers: {pragma: 'no-cache'}}),
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(result.score, 1);
       assert.equal(items.length, 0);
@@ -167,14 +169,14 @@ describe('Cache headers audit', () => {
   });
 
   it('ignores potentially uncacheable records', () => {
-    networkRecords = [
+    const networkRecords = [
       networkRecord({statusCode: 500}),
       networkRecord({url: 'https://example.com/dynamic.js?userId=crazy', transferSize: 10}),
       networkRecord({url: 'data:image/jpeg;base64,what'}),
       networkRecord({resourceType: NetworkRequest.TYPES.XHR}),
     ];
 
-    return CacheHeadersAudit.audit(artifacts, {options}).then(result => {
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), {options}).then(result => {
       assert.equal(result.score, 1);
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 1);
diff --git a/lighthouse-core/test/audits/critical-request-chains-test.js b/lighthouse-core/test/audits/critical-request-chains-test.js
index a7ce7ce9c..f5b3bacf2 100644
--- a/lighthouse-core/test/audits/critical-request-chains-test.js
+++ b/lighthouse-core/test/audits/critical-request-chains-test.js
@@ -7,6 +7,7 @@
 
 /* eslint-env jest */
 
+const Runner = require('../../runner.js');
 const CriticalRequestChains = require('../../audits/critical-request-chains.js');
 const assert = require('assert');
 
@@ -70,17 +71,14 @@ const PASSING_REQUEST_CHAIN_2 = {
 const EMPTY_REQUEST_CHAIN = {};
 
 const mockArtifacts = (mockChain) => {
-  return {
+  return Object.assign(Runner.instantiateComputedArtifacts(), {
     devtoolsLogs: {
       [CriticalRequestChains.DEFAULT_PASS]: [],
     },
-    requestNetworkRecords: () => {
-      return Promise.resolve([]);
-    },
     requestCriticalRequestChains: function() {
       return Promise.resolve(mockChain);
     },
-  };
+  });
 };
 
 describe('Performance: critical-request-chains audit', () => {
diff --git a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
index c225d7272..6c9151c61 100644
--- a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
@@ -40,7 +40,7 @@ describe('Avoids front-end JavaScript libraries with known vulnerabilities', ()
     assert.equal(auditResult.details.items[0].highestSeverity, 'High');
     assert.equal(auditResult.details.items[0].detectedLib.type, 'link');
     assert.equal(auditResult.details.items[0].detectedLib.text, 'angular@1.1.4');
-    assert.equal(auditResult.details.items[0].detectedLib.url, 'https://snyk.io/vuln/npm:angular?lh=1.1.4');
+    assert.equal(auditResult.details.items[0].detectedLib.url, 'https://snyk.io/vuln/npm:angular?lh=1.1.4&utm_source=lighthouse&utm_medium=ref&utm_campaign=audit');
   });
 
   it('handles ill-specified versions', () => {
diff --git a/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js b/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
index b20ecb67e..7d27a18b9 100644
--- a/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/uses-http2-test.js
@@ -5,8 +5,10 @@
  */
 'use strict';
 
+const Runner = require('../../../runner.js');
 const UsesHTTP2Audit = require('../../../audits/dobetterweb/uses-http2.js');
 const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 const URL = 'https://webtide.com/http2-push-demo/';
 const networkRecords = require('../../fixtures/networkRecords-mix.json');
@@ -14,12 +16,20 @@ const networkRecords = require('../../fixtures/networkRecords-mix.json');
 /* eslint-env jest */
 
 describe('Resources are fetched over http/2', () => {
+  function getArtifacts(networkRecords, finalUrl) {
+    // networkRecords-mix.json is an old network request format, so don't verify round-trip.
+    const devtoolsLog = networkRecordsToDevtoolsLog(networkRecords, {skipVerification: true});
+
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
+      URL: {finalUrl},
+      devtoolsLogs: {[UsesHTTP2Audit.DEFAULT_PASS]: devtoolsLog},
+    });
+  }
+
   it('fails when some resources were requested via http/1.x', () => {
-    return UsesHTTP2Audit.audit({
-      URL: {finalUrl: URL},
-      devtoolsLogs: {[UsesHTTP2Audit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
-    }).then(auditResult => {
+    return UsesHTTP2Audit.audit(
+      getArtifacts(networkRecords, URL)
+    ).then(auditResult => {
       assert.equal(auditResult.rawValue, false);
       assert.ok(auditResult.displayValue.match('3 requests not'));
       assert.equal(auditResult.details.items.length, 3);
@@ -32,11 +42,9 @@ describe('Resources are fetched over http/2', () => {
 
   it('displayValue is correct when only one resource fails', () => {
     const entryWithHTTP1 = networkRecords.slice(1, 2);
-    return UsesHTTP2Audit.audit({
-      URL: {finalUrl: URL},
-      devtoolsLogs: {[UsesHTTP2Audit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(entryWithHTTP1),
-    }).then(auditResult => {
+    return UsesHTTP2Audit.audit(
+      getArtifacts(entryWithHTTP1, URL)
+    ).then(auditResult => {
       assert.ok(auditResult.displayValue.match('1 request not'));
     });
   });
@@ -47,11 +55,9 @@ describe('Resources are fetched over http/2', () => {
       record.protocol = 'h2';
     });
 
-    return UsesHTTP2Audit.audit({
-      URL: {finalUrl: URL},
-      devtoolsLogs: {[UsesHTTP2Audit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(h2Records),
-    }).then(auditResult => {
+    return UsesHTTP2Audit.audit(
+      getArtifacts(h2Records, URL)
+    ).then(auditResult => {
       assert.equal(auditResult.rawValue, true);
       assert.ok(auditResult.displayValue === '');
     });
diff --git a/lighthouse-core/test/audits/font-display-test.js b/lighthouse-core/test/audits/font-display-test.js
index 1a836ba4c..fb4d9c62e 100644
--- a/lighthouse-core/test/audits/font-display-test.js
+++ b/lighthouse-core/test/audits/font-display-test.js
@@ -8,6 +8,8 @@
 const NetworkRequest = require('../../lib/network-request');
 const Audit = require('../../audits/font-display.js');
 const assert = require('assert');
+const Runner = require('../../runner.js');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 const openSansFont = {
@@ -34,11 +36,10 @@ const openSansFontBold = {
 
 describe('Performance: Font Display audit', () => {
   function getArtifacts(networkRecords, fonts) {
-    return {
-      devtoolsLogs: {[Audit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    return Object.assign({
+      devtoolsLogs: {[Audit.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       Fonts: fonts,
-    };
+    }, Runner.instantiateComputedArtifacts());
   }
 
   it('fails when not all fonts have a correct font-display rule', () => {
diff --git a/lighthouse-core/test/audits/is-on-https-test.js b/lighthouse-core/test/audits/is-on-https-test.js
index c817fbec4..f158f30fb 100644
--- a/lighthouse-core/test/audits/is-on-https-test.js
+++ b/lighthouse-core/test/audits/is-on-https-test.js
@@ -5,17 +5,19 @@
  */
 'use strict';
 
+const Runner = require('../../runner.js');
 const Audit = require('../../audits/is-on-https.js');
 const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
 describe('Security: HTTPS audit', () => {
   function getArtifacts(networkRecords) {
-    return {
-      devtoolsLogs: {[Audit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
-    };
+    const devtoolsLog = networkRecordsToDevtoolsLog(networkRecords);
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[Audit.DEFAULT_PASS]: devtoolsLog},
+    });
   }
 
   it('fails when there is more than one insecure record', () => {
diff --git a/lighthouse-core/test/audits/mixed-content-test.js b/lighthouse-core/test/audits/mixed-content-test.js
index ed0cbf432..c36023597 100644
--- a/lighthouse-core/test/audits/mixed-content-test.js
+++ b/lighthouse-core/test/audits/mixed-content-test.js
@@ -7,22 +7,23 @@
 
 const Audit = require('../../audits/mixed-content.js');
 const assert = require('assert');
+const Runner = require('../../runner');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
 describe('Mixed Content audit', () => {
   function getArtifacts(baseUrl, defaultPassRecords, mixedContentPassRecords) {
-    return {
+    const defaultDevtoolsLog = networkRecordsToDevtoolsLog(defaultPassRecords);
+    const mixedContentDevtoolsLog = networkRecordsToDevtoolsLog(mixedContentPassRecords);
+
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
       MixedContent: {url: baseUrl},
-      devtoolsLogs: {[Audit.DEFAULT_PASS]: true, ['mixedContentPass']: false},
-      requestNetworkRecords: (pass) => {
-        if (pass) {
-          return Promise.resolve(defaultPassRecords);
-        } else {
-          return Promise.resolve(mixedContentPassRecords);
-        }
+      devtoolsLogs: {
+        [Audit.DEFAULT_PASS]: defaultDevtoolsLog,
+        ['mixedContentPass']: mixedContentDevtoolsLog,
       },
-    };
+    });
   }
 
   it('passes when there are no insecure resources by default', () => {
@@ -55,7 +56,7 @@ describe('Mixed Content audit', () => {
       {url: 'https://example.org/', isSecure: true, finished: true, documentURL: 'http://example.org/'},
       {url: 'https://example.org/resource1.js', isSecure: true, finished: true, documentURL: 'https://example.org'},
       {url: 'https://third-party.example.com/resource2.js', isSecure: true, finished: true, documentURL: 'https://example.org'},
-      {url: 'https://fourth-party.example.com/resource3.js', isSecure: false, finished: true, documentURL: 'https://third-party.example.com'},
+      {url: 'https://fourth-party.example.com/resource3.js', isSecure: true, finished: true, documentURL: 'https://third-party.example.com'},
     ];
     return Audit.audit(
       getArtifacts('http://example.org', defaultRecords, upgradeRecords)
diff --git a/lighthouse-core/test/audits/network-requests-test.js b/lighthouse-core/test/audits/network-requests-test.js
index f9b5e2692..12b442ad8 100644
--- a/lighthouse-core/test/audits/network-requests-test.js
+++ b/lighthouse-core/test/audits/network-requests-test.js
@@ -8,6 +8,7 @@
 const NetworkRequests = require('../../audits/network-requests');
 const Runner = require('../../runner.js');
 const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 const acceptableDevToolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json');
 
@@ -38,7 +39,11 @@ describe('Network requests audit', () => {
       {url: 'https://example.com/1', startTime: 15.5, endTime: -1},
     ];
 
-    const artifacts = {devtoolsLogs: {}, requestNetworkRecords: () => Promise.resolve(records)};
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {
+        [NetworkRequests.DEFAULT_PASS]: networkRecordsToDevtoolsLog(records),
+      },
+    });
     const output = await NetworkRequests.audit(artifacts);
     assert.equal(output.details.items[0].startTime, 0);
     assert.equal(output.details.items[0].endTime, 500);
diff --git a/lighthouse-core/test/audits/redirects-test.js b/lighthouse-core/test/audits/redirects-test.js
index c1624ec68..ecfb76adb 100644
--- a/lighthouse-core/test/audits/redirects-test.js
+++ b/lighthouse-core/test/audits/redirects-test.js
@@ -5,7 +5,8 @@
  */
 'use strict';
 
-const Audit = require('../../audits/redirects.js');
+const Runner = require('../../runner.js');
+const RedirectsAudit = require('../../audits/redirects.js');
 const assert = require('assert');
 
 /* eslint-env jest */
@@ -62,16 +63,15 @@ describe('Performance: Redirects audit', () => {
   let nodeTimings;
 
   const mockArtifacts = (mockChain) => {
-    return {
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
       traces: {},
-      devtoolsLogs: {},
+      devtoolsLogs: {defaultPass: []},
       requestLanternInteractive: () => ({pessimisticEstimate: {nodeTimings}}),
       requestTraceOfTab: () => {},
-      requestNetworkRecords: () => [],
       requestMainResource: function() {
         return Promise.resolve(mockChain);
       },
-    };
+    });
   };
 
   it('fails when 3 redirects detected', () => {
@@ -82,7 +82,7 @@ describe('Performance: Redirects audit', () => {
       [{type: 'network', record: {url: 'https://m.example.com/final'}}, {startTime: 17000}],
     ]);
 
-    return Audit.audit(mockArtifacts(FAILING_THREE_REDIRECTS), {}).then(output => {
+    return RedirectsAudit.audit(mockArtifacts(FAILING_THREE_REDIRECTS), {}).then(output => {
       assert.equal(output.score, 0);
       assert.equal(output.details.items.length, 4);
       assert.equal(output.rawValue, 17000);
@@ -96,7 +96,7 @@ describe('Performance: Redirects audit', () => {
       [{type: 'network', record: {url: 'https://www.lisairish.com/'}}, {startTime: 638}],
     ]);
 
-    return Audit.audit(mockArtifacts(FAILING_TWO_REDIRECTS), {}).then(output => {
+    return RedirectsAudit.audit(mockArtifacts(FAILING_TWO_REDIRECTS), {}).then(output => {
       assert.equal(Math.round(output.score * 100) / 100, 0.56);
       assert.equal(output.details.items.length, 3);
       assert.equal(Math.round(output.rawValue), 638);
@@ -109,7 +109,7 @@ describe('Performance: Redirects audit', () => {
       [{type: 'network', record: {url: 'https://www.lisairish.com/'}}, {startTime: 510}],
     ]);
 
-    return Audit.audit(mockArtifacts(SUCCESS_ONE_REDIRECT), {}).then(output => {
+    return RedirectsAudit.audit(mockArtifacts(SUCCESS_ONE_REDIRECT), {}).then(output => {
       // If === 1 redirect, perfect score is expected, regardless of latency
       assert.equal(output.score, 1);
       // We will still generate a table and show wasted time
@@ -119,7 +119,7 @@ describe('Performance: Redirects audit', () => {
   });
 
   it('passes when no redirect detected', () => {
-    return Audit.audit(mockArtifacts(SUCCESS_NOREDIRECT), {}).then(output => {
+    return RedirectsAudit.audit(mockArtifacts(SUCCESS_NOREDIRECT), {}).then(output => {
       assert.equal(output.score, 1);
       assert.equal(output.details.items.length, 0);
       assert.equal(output.rawValue, 0);
diff --git a/lighthouse-core/test/audits/seo/http-status-code-test.js b/lighthouse-core/test/audits/seo/http-status-code-test.js
index 38ba93c69..8dbde2d3f 100644
--- a/lighthouse-core/test/audits/seo/http-status-code-test.js
+++ b/lighthouse-core/test/audits/seo/http-status-code-test.js
@@ -7,6 +7,8 @@
 
 const HTTPStatusCodeAudit = require('../../../audits/seo/http-status-code.js');
 const assert = require('assert');
+const Runner = require('../../../runner.js');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
 
@@ -15,14 +17,17 @@ describe('SEO: HTTP code audit', () => {
     const statusCodes = [403, 404, 500];
 
     const allRuns = statusCodes.map(statusCode => {
+      const finalUrl = 'https://example.com';
       const mainResource = {
+        url: finalUrl,
         statusCode,
       };
-      const artifacts = {
-        devtoolsLogs: {[HTTPStatusCodeAudit.DEFAULT_PASS]: []},
-        requestNetworkRecords: () => Promise.resolve(),
-        requestMainResource: () => Promise.resolve(mainResource),
-      };
+      const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
+
+      const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+        devtoolsLogs: {[HTTPStatusCodeAudit.DEFAULT_PASS]: devtoolsLog},
+        URL: {finalUrl},
+      });
 
       return HTTPStatusCodeAudit.audit(artifacts).then(auditResult => {
         assert.equal(auditResult.rawValue, false);
@@ -34,15 +39,17 @@ describe('SEO: HTTP code audit', () => {
   });
 
   it('passes when status code is successful', () => {
+    const finalUrl = 'https://example.com';
     const mainResource = {
+      url: finalUrl,
       statusCode: 200,
     };
+    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
 
-    const artifacts = {
-      devtoolsLogs: {[HTTPStatusCodeAudit.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(),
-      requestMainResource: () => Promise.resolve(mainResource),
-    };
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[HTTPStatusCodeAudit.DEFAULT_PASS]: devtoolsLog},
+      URL: {finalUrl},
+    });
 
     return HTTPStatusCodeAudit.audit(artifacts).then(auditResult => {
       assert.equal(auditResult.rawValue, true);
diff --git a/lighthouse-core/test/audits/uses-rel-preconnect-test.js b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
index f8a414ef4..0efec767f 100644
--- a/lighthouse-core/test/audits/uses-rel-preconnect-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
@@ -10,6 +10,8 @@
 
 const UsesRelPreconnect = require('../../audits/uses-rel-preconnect.js');
 const assert = require('assert');
+const Runner = require('../../runner.js');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 const mainResource = {
   url: 'https://www.example.com/',
@@ -36,17 +38,13 @@ describe('Performance: uses-rel-preconnect audit', () => {
       mainResource,
       {
         url: 'https://www.example.com/request',
-        parsedURL: {
-          securityOrigin: 'https://www.example.com',
-        },
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(score, 1);
@@ -62,12 +60,11 @@ describe('Performance: uses-rel-preconnect audit', () => {
         initiator: mainResource,
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(score, 1);
@@ -83,12 +80,11 @@ describe('Performance: uses-rel-preconnect audit', () => {
         initiator: {},
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(score, 1);
@@ -110,12 +106,11 @@ describe('Performance: uses-rel-preconnect audit', () => {
         },
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(score, 1);
@@ -129,15 +124,14 @@ describe('Performance: uses-rel-preconnect audit', () => {
       {
         url: 'https://cdn.example.com/request',
         initiator: {},
-        _startTime: 16,
+        startTime: 16,
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(score, 1);
@@ -151,10 +145,6 @@ describe('Performance: uses-rel-preconnect audit', () => {
       {
         url: 'https://cdn.example.com/first',
         initiator: {},
-        parsedURL: {
-          scheme: 'https',
-          securityOrigin: 'https://cdn.example.com',
-        },
         startTime: 2,
         timing: {
           dnsStart: 100,
@@ -165,10 +155,6 @@ describe('Performance: uses-rel-preconnect audit', () => {
       {
         url: 'https://cdn.example.com/second',
         initiator: {},
-        parsedURL: {
-          scheme: 'https',
-          securityOrigin: 'https://cdn.example.com',
-        },
         startTime: 3,
         timing: {
           dnsStart: 300,
@@ -177,12 +163,11 @@ describe('Performance: uses-rel-preconnect audit', () => {
         },
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     const {rawValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, {});
     assert.equal(rawValue, 200);
@@ -196,12 +181,8 @@ describe('Performance: uses-rel-preconnect audit', () => {
     const networkRecords = [
       mainResource,
       {
-        url: 'https://cdn.example.com/first',
+        url: 'http://cdn.example.com/first',
         initiator: {},
-        parsedURL: {
-          scheme: 'http',
-          securityOrigin: 'http://cdn.example.com',
-        },
         startTime: 2,
         timing: {
           dnsStart: 100,
@@ -212,10 +193,6 @@ describe('Performance: uses-rel-preconnect audit', () => {
       {
         url: 'https://othercdn.example.com/second',
         initiator: {},
-        parsedURL: {
-          scheme: 'https',
-          securityOrigin: 'https://othercdn.example.com',
-        },
         startTime: 1.2,
         timing: {
           dnsStart: 100,
@@ -224,12 +201,11 @@ describe('Performance: uses-rel-preconnect audit', () => {
         },
       },
     ];
-    const artifacts = {
-      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+    const artifacts = Object.assign(Runner.instantiateComputedArtifacts(), {
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestMainResource: () => Promise.resolve(mainResource),
       requestLoadSimulator: () => Promise.resolve(simulator),
-    };
+    });
 
     simulatorOptions = {
       rtt: 100,
diff --git a/lighthouse-core/test/audits/uses-rel-preload-test.js b/lighthouse-core/test/audits/uses-rel-preload-test.js
index 9b0ce4889..aee4cb13c 100644
--- a/lighthouse-core/test/audits/uses-rel-preload-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preload-test.js
@@ -15,6 +15,7 @@ const assert = require('assert');
 const Runner = require('../../runner');
 const pwaTrace = require('../fixtures/traces/progressive-app-m60.json');
 const pwaDevtoolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 const defaultMainResource = {
   _endTime: 1,
@@ -25,16 +26,15 @@ describe('Performance: uses-rel-preload audit', () => {
   let mockSimulator;
 
   const mockArtifacts = (networkRecords, mainResource = defaultMainResource) => {
-    return {
+    return Object.assign(Runner.instantiateComputedArtifacts(), {
       traces: {[UsesRelPreload.DEFAULT_PASS]: {traceEvents: []}},
-      devtoolsLogs: {[UsesRelPreload.DEFAULT_PASS]: []},
+      devtoolsLogs: {[UsesRelPreload.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       requestLoadSimulator: () => mockSimulator,
       requestPageDependencyGraph: () => mockGraph,
-      requestNetworkRecords: () => networkRecords,
       requestMainResource: () => {
         return Promise.resolve(mainResource);
       },
-    };
+    });
   };
 
   afterEach(() => {
@@ -50,11 +50,9 @@ describe('Performance: uses-rel-preload audit', () => {
     const networkRecords = [
       {
         requestId: '2',
-        resourceType: 'Document',
         priority: 'High',
         isLinkPreload: false,
         url: 'http://example.com:3000',
-        redirects: [''],
       },
       {
         requestId: '2:redirect',
@@ -62,7 +60,6 @@ describe('Performance: uses-rel-preload audit', () => {
         priority: 'High',
         isLinkPreload: false,
         url: 'http://www.example.com:3000',
-        redirects: [''],
       },
       {
         requestId: '3',
@@ -160,7 +157,7 @@ describe('Performance: uses-rel-preload audit', () => {
     const networkRecords = [
       {
         requestId: '3',
-        _startTime: 10,
+        startTime: 10,
         isLinkPreload: true,
         url: 'http://www.example.com/script.js',
       },
@@ -177,7 +174,7 @@ describe('Performance: uses-rel-preload audit', () => {
       {
         requestId: '3',
         protocol: 'data',
-        _startTime: 10,
+        startTime: 10,
       },
     ];
 
@@ -192,7 +189,7 @@ describe('Performance: uses-rel-preload audit', () => {
       {
         requestId: '3',
         protocol: 'blob',
-        _startTime: 10,
+        startTime: 10,
       },
     ];
 
diff --git a/lighthouse-core/test/gather/computed/main-resource-test.js b/lighthouse-core/test/gather/computed/main-resource-test.js
index cfd15d4c4..4ee2af556 100644
--- a/lighthouse-core/test/gather/computed/main-resource-test.js
+++ b/lighthouse-core/test/gather/computed/main-resource-test.js
@@ -9,6 +9,7 @@
 
 const Runner = require('../../../runner.js');
 const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 describe('MainResource computed artifact', () => {
   let computedArtifacts;
@@ -25,11 +26,11 @@ describe('MainResource computed artifact', () => {
       {url: 'http://example.com'},
       record,
     ];
-    computedArtifacts.requestNetworkRecords = _ => Promise.resolve(networkRecords);
     const URL = {finalUrl: 'https://example.com'};
+    const devtoolsLog = networkRecordsToDevtoolsLog(networkRecords);
 
-    return computedArtifacts.requestMainResource({URL}).then(output => {
-      assert.equal(output, record);
+    return computedArtifacts.requestMainResource({URL, devtoolsLog}).then(output => {
+      assert.equal(output.url, record.url);
     });
   });
 
@@ -37,10 +38,10 @@ describe('MainResource computed artifact', () => {
     const networkRecords = [
       {url: 'https://example.com'},
     ];
-    computedArtifacts.requestNetworkRecords = _ => Promise.resolve(networkRecords);
     const URL = {finalUrl: 'https://m.example.com'};
+    const devtoolsLog = networkRecordsToDevtoolsLog(networkRecords);
 
-    return computedArtifacts.requestMainResource({URL}).then(() => {
+    return computedArtifacts.requestMainResource({URL, devtoolsLog}).then(() => {
       assert.ok(false, 'should have thrown');
     }).catch(err => {
       assert.equal(err.message, 'Unable to identify the main resource');
@@ -63,9 +64,9 @@ describe('MainResource computed artifact', () => {
       {url: 'https://beta.httparchive.org/reports/state-of-the-web'},
     ];
 
-    computedArtifacts.requestNetworkRecords = _ => Promise.resolve(networkRecords);
     const URL = {finalUrl: 'https://beta.httparchive.org/reports/state-of-the-web#pctHttps'};
-    const artifacts = {URL};
+    const devtoolsLog = networkRecordsToDevtoolsLog(networkRecords);
+    const artifacts = {URL, devtoolsLog};
 
     return computedArtifacts.requestMainResource(artifacts).then(output => {
       assert.equal(output.url, 'https://beta.httparchive.org/reports/state-of-the-web');
diff --git a/lighthouse-core/test/network-records-to-devtools-log.js b/lighthouse-core/test/network-records-to-devtools-log.js
new file mode 100644
index 000000000..4847e99d3
--- /dev/null
+++ b/lighthouse-core/test/network-records-to-devtools-log.js
@@ -0,0 +1,210 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const NetworkRecorder = require('../../lighthouse-core/lib/network-recorder.js');
+/** @typedef {import('../../lighthouse-core/lib/network-request.js')} NetworkRequest */
+
+const idBase = '127122';
+const exampleUrl = 'https://testingurl.com/';
+const redirectSuffix = ':redirect';
+
+/* eslint-env jest */
+
+/**
+ * Extract requestId without any `:redirect` strings.
+ * @param {Partial<NetworkRequest>} requestId
+ */
+function getBaseRequestId(record) {
+  if (!record.requestId) return;
+
+  const match = /^([\w.]+)(?::redirect)*$/.exec(record.requestId);
+  return match && match[1];
+}
+
+/**
+ * @param {Array<HeaderEntry>=} headersArray
+ * @return {LH.Crdp.Network.Headers}
+ */
+function headersArrayToHeadersDict(headersArray = []) {
+  const headersDict = {};
+  headersArray.forEach(headerItem => {
+    const value = headersDict[headerItem.name] ? headersDict[headerItem.name] + '\n' : '';
+    headersDict[headerItem.name] = value + headerItem.value;
+  });
+
+  return headersDict;
+}
+
+/**
+ * @param {Partial<NetworkRequest>} networkRecord
+ * @return {LH.Protocol.RawEventMessage}
+ */
+function getRequestWillBeSentEvent(networkRecord, index) {
+  const initiator = networkRecord.isLinkPreload ? {type: 'preload'} :
+      networkRecord.initiator || {type: 'other'};
+
+  return {
+    method: 'Network.requestWillBeSent',
+    params: {
+      requestId: getBaseRequestId(networkRecord) || `${idBase}.${index}`,
+      documentURL: networkRecord.documentURL || exampleUrl,
+      request: {
+        url: networkRecord.url || exampleUrl,
+        method: networkRecord.requestMethod || 'GET',
+        headers: {},
+        initialPriority: networkRecord.priority || 'Low',
+      },
+      timestamp: networkRecord.startTime || 0,
+      wallTime: 0,
+      initiator: initiator,
+      type: networkRecord.resourceType || 'Document',
+      frameId: `${idBase}.1`,
+      redirectResponse: networkRecord.redirectResponse,
+    },
+  };
+}
+
+/**
+ * @param {Partial<NetworkRequest>} networkRecord
+ * @return {LH.Protocol.RawEventMessage}
+ */
+function getResponseReceivedEvent(networkRecord, index) {
+  const headers = headersArrayToHeadersDict(networkRecord.responseHeaders);
+  const timing = networkRecord.timing;
+  if (timing && timing.requestTime === undefined) {
+    timing.requestTime = networkRecord.startTime || 0;
+  }
+
+  return {
+    method: 'Network.responseReceived',
+    params: {
+      requestId: getBaseRequestId(networkRecord) || `${idBase}.${index}`,
+      timestamp: networkRecord.responseReceivedTime || 1,
+      type: networkRecord.resourceType || undefined,
+      response: {
+        url: networkRecord.url || exampleUrl,
+        status: networkRecord.statusCode || 200,
+        headers,
+        mimeType: networkRecord.mimeType || 'text/html',
+        connectionReused: networkRecord.connectionReused || false,
+        connectionId: networkRecord.connectionId || 140,
+        fromDiskCache: networkRecord.fromDiskCache || undefined,
+        fromServiceWorker: networkRecord.fromServiceWorker || undefined,
+        encodedDataLength: networkRecord.transferSize || 0,
+        timing,
+        protocol: networkRecord.protocol || 'http/1.1',
+      },
+      frameId: `${idBase}.1`,
+    },
+  };
+}
+
+/**
+ * @param {Partial<NetworkRequest>} networkRecord
+ * @return {LH.Protocol.RawEventMessage}
+ */
+function getDataReceivedEvent(networkRecord, index) {
+  return {
+    method: 'Network.dataReceived',
+    params: {
+      requestId: getBaseRequestId(networkRecord) || `${idBase}.${index}`,
+      dataLength: networkRecord.resourceSize || 0,
+      encodedDataLength: networkRecord.transferSize || 0,
+    },
+  };
+}
+
+/**
+ * @param {Partial<NetworkRequest>} networkRecord
+ * @return {LH.Protocol.RawEventMessage}
+ */
+function getLoadingFinishedEvent(networkRecord, index) {
+  return {
+    method: 'Network.loadingFinished',
+    params: {
+      requestId: getBaseRequestId(networkRecord) || `${idBase}.${index}`,
+      timestamp: networkRecord.endTime || 3,
+      encodedDataLength: networkRecord.transferSize || 0,
+    },
+  };
+}
+
+/**
+ * Returns true if `record` is redirected by another record.
+ * @param {Array<Partial<NetworkRequest>>} networkRecords
+ * @param {Partial<NetworkRequest>} record
+ * @return {boolean}
+ */
+function willBeRedirected(networkRecords, record) {
+  if (!record.requestId) {
+    return false;
+  }
+
+  const redirectId = record.requestId + redirectSuffix;
+  return networkRecords.some(otherRecord => otherRecord.requestId === redirectId);
+}
+
+/**
+ * If `record` is a redirect of another record, create a fake redirect respose
+ * to keep the original request defined correctly.
+ * @param {Array<Partial<NetworkRequest>>} networkRecords
+ * @param {Partial<NetworkRequest>} record
+ * @return {Partial<NetworkRequest>}
+ */
+function addRedirectResponseIfNeeded(networkRecords, record) {
+  if (!record.requestId || !record.requestId.endsWith(redirectSuffix)) {
+    return record;
+  }
+
+  const originalId = record.requestId.slice(0, -redirectSuffix.length);
+  const originalRecord = networkRecords.find(record => record.requestId === originalId);
+  if (!originalRecord) {
+    throw new Error(`redirect with id ${record.requestId} has no original request`);
+  }
+
+  // populate `redirectResponse` with original's data, more or less.
+  const originalResponse = getResponseReceivedEvent(originalRecord).params.response;
+  originalResponse.status = originalRecord.statusCode || 302;
+  return Object.assign({}, record, {redirectResponse: originalResponse});
+}
+
+/**
+ * Generate a devtoolsLog that can regenerate the passed-in `networkRecords`.
+ * Generally best at replicating artificial or pruned networkRecords used for
+ * testing. If run from a test runner, verifies that everything in
+ * `networkRecords` will be in any network records generated from the output
+ * (use `skipVerification` to manually skip this assertion).
+ * @param {Array<Partial<NetworkRequest>>} networkRecords
+ * @param {{skipVerification?: boolean}=} options
+ * @return {LH.DevtoolsLog}
+ */
+function networkRecordsToDevtoolsLog(networkRecords, options = {}) {
+  const devtoolsLog = [];
+  networkRecords.forEach((networkRecord, index) => {
+    networkRecord = addRedirectResponseIfNeeded(networkRecords, networkRecord);
+    devtoolsLog.push(getRequestWillBeSentEvent(networkRecord, index));
+
+    if (willBeRedirected(networkRecords, networkRecord)) {
+      // If record is going to redirect, only issue the first event.
+      return;
+    }
+
+    devtoolsLog.push(getResponseReceivedEvent(networkRecord, index));
+    devtoolsLog.push(getDataReceivedEvent(networkRecord, index));
+    devtoolsLog.push(getLoadingFinishedEvent(networkRecord, index));
+  });
+
+  // If in a test, assert that the log will turn into an equivalent networkRecords.
+  if (global.expect && !options.skipVerification) {
+    const roundTrippedNetworkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
+    expect(roundTrippedNetworkRecords).toMatchObject(networkRecords);
+  }
+
+  return devtoolsLog;
+}
+
+module.exports = networkRecordsToDevtoolsLog;
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 7451b200a..9c93ce33b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2376,7 +2376,7 @@
             "vulnCount": 1,
             "detectedLib": {
               "text": "jQuery@2.1.1",
-              "url": "https://snyk.io/vuln/npm:jquery?lh=2.1.1",
+              "url": "https://snyk.io/vuln/npm:jquery?lh=2.1.1&utm_source=lighthouse&utm_medium=ref&utm_campaign=audit",
               "type": "link"
             }
           }
diff --git a/lighthouse-extension/test/app/src/lighthouse-ext-background-test.js b/lighthouse-extension/test/app/src/lighthouse-lr-background-test.js
similarity index 95%
rename from lighthouse-extension/test/app/src/lighthouse-ext-background-test.js
rename to lighthouse-extension/test/app/src/lighthouse-lr-background-test.js
index c66858d8e..757224c31 100644
--- a/lighthouse-extension/test/app/src/lighthouse-ext-background-test.js
+++ b/lighthouse-extension/test/app/src/lighthouse-lr-background-test.js
@@ -6,12 +6,12 @@
 'use strict';
 
 const assert = require('assert');
-const lhBackground = require('../../../app/src/lighthouse-ext-background.js');
+const lhBackground = require('../../../app/src/lighthouse-lr-background.js');
 const LHError = require('../../../../lighthouse-core/lib/lh-error.js');
 
 /* eslint-env mocha */
 
-describe('lighthouse-ext-background', () => {
+describe('lighthouse-lr-background', () => {
   describe('#runLighthouseInLR', () => {
     it('returns a runtimeError LHR when lighthouse throws a runtimeError', async () => {
       const connectionError = new LHError(LHError.errors.FAILED_DOCUMENT_REQUEST);