Repository: GoogleChrome/lighthouse
Pull request #12808 (2021-07-20 15:44:05)

core(fr): support config filtering via only/skip settings
**Summary**
Implements the `onlyAudits`/`onlyCategories`/`skipAudits` functionality for the FR config. This will unlock smoketest running in FR navigation runner.

**Related Issues/PRs**
ref #12805 #11313 



Patch:
diff --git a/lighthouse-core/fraggle-rock/config/config.js b/lighthouse-core/fraggle-rock/config/config.js
index 2d56c0cc2..c5f57d9bc 100644
--- a/lighthouse-core/fraggle-rock/config/config.js
+++ b/lighthouse-core/fraggle-rock/config/config.js
@@ -17,7 +17,7 @@ const {
   throwInvalidArtifactDependency,
   assertArtifactTopologicalOrder,
 } = require('./validation.js');
-const {filterConfigByGatherMode} = require('./filters.js');
+const {filterConfigByGatherMode, filterConfigByExplicitFilters} = require('./filters.js');
 const {
   deepCloneConfigJson,
   resolveSettings,
@@ -192,10 +192,9 @@ function initializeConfig(configJSON, context) {
   // TODO(FR-COMPAT): validate navigations
   // TODO(FR-COMPAT): validate audits
   // TODO(FR-COMPAT): validate categories
-  // TODO(FR-COMPAT): filter config using onlyAudits/onlyCategories
-  // TODO(FR-COMPAT): always keep base/shared artifacts/audits (Stacks, FullPageScreenshot, etc)
 
   config = filterConfigByGatherMode(config, context.gatherMode);
+  config = filterConfigByExplicitFilters(config, settings);
 
   log.timeEnd(status);
   return {config, warnings: []};
diff --git a/lighthouse-core/fraggle-rock/config/filters.js b/lighthouse-core/fraggle-rock/config/filters.js
index c65cb419c..5af6323fd 100644
--- a/lighthouse-core/fraggle-rock/config/filters.js
+++ b/lighthouse-core/fraggle-rock/config/filters.js
@@ -20,6 +20,67 @@ const baseArtifactKeySource = {
 
 const baseArtifactKeys = Object.keys(baseArtifactKeySource);
 
+// Some audits are used by the report for additional information.
+// Keep these audits unless they are *directly* skipped with `skipAudits`.
+const filterResistantAuditIds = ['full-page-screenshot'];
+
+/**
+ * Returns the set of audit IDs used in the list of categories.
+ * If `onlyCategories` is not set, this function returns the list of all audit IDs across all
+ * categories.
+ *
+ * @param {LH.Config.FRConfig['categories']} allCategories
+ * @param {string[] | undefined} onlyCategories
+ * @return {Set<string>}
+ */
+function getAuditIdsInCategories(allCategories, onlyCategories) {
+  if (!allCategories) return new Set();
+
+  onlyCategories = onlyCategories || Object.keys(allCategories);
+  const categories = onlyCategories.map(categoryId => allCategories[categoryId]);
+  const auditRefs = categories.flatMap(category => category.auditRefs);
+  return new Set(auditRefs.map(auditRef => auditRef.id));
+}
+
+/**
+ * Filters an array of artifacts down to the set that's required by the specified audits.
+ *
+ * @param {LH.Config.FRConfig['artifacts']} artifacts
+ * @param {LH.Config.FRConfig['audits']} audits
+ * @return {LH.Config.FRConfig['artifacts']}
+ */
+function filterArtifactsByAvailableAudits(artifacts, audits) {
+  if (!artifacts) return null;
+  if (!audits) return artifacts;
+
+  const artifactsById = new Map(artifacts.map(artifact => [artifact.id, artifact]));
+
+  /** @type {Set<string>} */
+  const artifactIdsToKeep = new Set(
+    audits.flatMap(audit => audit.implementation.meta.requiredArtifacts)
+  );
+
+  // Keep all artifacts in the dependency tree of required artifacts.
+  // Iterate through all kept artifacts, adding their dependencies along the way, until the set does not change.
+  let previousSize = 0;
+  while (previousSize !== artifactIdsToKeep.size) {
+    previousSize = artifactIdsToKeep.size;
+    for (const artifactId of artifactIdsToKeep) {
+      const artifact = artifactsById.get(artifactId);
+      // This shouldn't happen because the config has passed validation by this point.
+      if (!artifact) continue;
+      // If the artifact doesn't have any dependencies, we can move on.
+      if (!artifact.dependencies) continue;
+      // Add all of the artifact's dependencies to our set.
+      for (const dep of Object.values(artifact.dependencies)) {
+        artifactIdsToKeep.add(dep.id);
+      }
+    }
+  }
+
+  return artifacts.filter(artifact => artifactIdsToKeep.has(artifact.id));
+}
+
 /**
  * Filters an array of artifacts down to the set that supports the specified gather mode.
  *
@@ -34,6 +95,30 @@ function filterArtifactsByGatherMode(artifacts, mode) {
   });
 }
 
+/**
+ * Filters an array of navigations down to the set supported by the available artifacts.
+ *
+ * @param {LH.Config.FRConfig['navigations']} navigations
+ * @param {Array<LH.Config.AnyArtifactDefn>} availableArtifacts
+ * @return {LH.Config.FRConfig['navigations']}
+ */
+function filterNavigationsByAvailableArtifacts(navigations, availableArtifacts) {
+  if (!navigations) return navigations;
+
+  const availableArtifactIds = new Set(
+    availableArtifacts.map(artifact => artifact.id).concat(baseArtifactKeys)
+  );
+
+  return navigations
+    .map(navigation => {
+      return {
+        ...navigation,
+        artifacts: navigation.artifacts.filter((artifact) => availableArtifactIds.has(artifact.id)),
+      };
+    })
+    .filter(navigation => navigation.artifacts.length);
+}
+
 /**
  * Filters an array of audits down to the set that can be computed using only the specified artifacts.
  *
@@ -69,6 +154,21 @@ function filterAuditsByGatherMode(audits, mode) {
   });
 }
 
+/**
+ * Filters a categories object and their auditRefs down to the specified category ids.
+ *
+ * @param {LH.Config.Config['categories']} categories
+ * @param {string[] | null | undefined} onlyCategories
+ * @return {LH.Config.Config['categories']}
+ */
+function filterCategoriesByExplicitFilters(categories, onlyCategories) {
+  if (!categories || !onlyCategories) return categories;
+
+  const categoriesToKeep = Object.entries(categories)
+    .filter(([categoryId]) => onlyCategories.includes(categoryId));
+  return Object.fromEntries(categoriesToKeep);
+}
+
 /**
  * Filters a categories object and their auditRefs down to the set that can be computed using
  * only the specified audits.
@@ -129,10 +229,58 @@ function filterConfigByGatherMode(config, mode) {
   };
 }
 
+/**
+ * Filters a config's artifacts, audits, and categories down to the requested set.
+ * Skip audits overrides inclusion via `onlyAudits`/`onlyCategories`.
+ *
+ * @param {LH.Config.FRConfig} config
+ * @param {Pick<LH.Config.Settings, 'onlyAudits'|'onlyCategories'|'skipAudits'>} filters
+ * @return {LH.Config.FRConfig}
+ */
+function filterConfigByExplicitFilters(config, filters) {
+  const {onlyAudits, onlyCategories, skipAudits} = filters;
+
+  let baseAuditIds = getAuditIdsInCategories(config.categories, undefined);
+  if (onlyCategories) {
+    baseAuditIds = getAuditIdsInCategories(config.categories, onlyCategories);
+  } else if (onlyAudits) {
+    baseAuditIds = new Set();
+  }
+
+  const auditIdsToKeep = new Set(
+    [
+      ...baseAuditIds, // Start with our base audits.
+      ...(onlyAudits || []), // Additionally include the opt-in audits from `onlyAudits`.
+      ...filterResistantAuditIds, // Always include our filter-resistant audits (full-page-screenshot).
+    ].filter(auditId => !skipAudits || !skipAudits.includes(auditId))
+  );
+
+  const audits = auditIdsToKeep.size && config.audits ?
+    config.audits.filter(audit => auditIdsToKeep.has(audit.implementation.meta.id)) :
+    config.audits;
+
+  const availableCategories = filterCategoriesByAvailableAudits(config.categories, audits || []);
+  const categories = filterCategoriesByExplicitFilters(availableCategories, onlyCategories);
+  const artifacts = filterArtifactsByAvailableAudits(config.artifacts, audits);
+  const navigations = filterNavigationsByAvailableArtifacts(config.navigations, artifacts || []);
+
+  return {
+    ...config,
+    artifacts,
+    navigations,
+    audits,
+    categories,
+  };
+}
+
 module.exports = {
   filterConfigByGatherMode,
+  filterConfigByExplicitFilters,
   filterArtifactsByGatherMode,
+  filterArtifactsByAvailableAudits,
+  filterNavigationsByAvailableArtifacts,
   filterAuditsByAvailableArtifacts,
   filterAuditsByGatherMode,
   filterCategoriesByAvailableAudits,
+  filterCategoriesByExplicitFilters,
 };
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 13921cd1f..4526dd5b0 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -18,18 +18,30 @@
 
   export type ExpectedRunnerResult = {
     lhr: ExpectedLHR,
-    artifacts?: Partial<Record<keyof LH.Artifacts, any>>
+    artifacts?: Partial<Record<keyof LH.Artifacts|'_maxChromiumMilestone'|'_minChromiumMilestone', any>>
     networkRequests?: {length: number};
   }
 
   export interface TestDfn {
+    /** Identification of test. Can be used for group selection (e.g. `yarn smoke pwa` will run all tests with `id.includes('pwa')`). */
     id: string;
-    expectations: ExpectedRunnerResult[];
+    /** Expected test results. */
+    expectations: ExpectedRunnerResult;
+    /** An optional custom config. If none is present, uses the default Lighthouse config. */
     config?: LH.Config.Json;
     /** If test is performance sensitive, set to true so that it won't be run parallel to other tests. */
     runSerially?: boolean;
   }
 
+  /**
+   * A TestDefn type that's compatible with the old array of `expectations` type and the current TestDefn type.
+   * COMPAT: remove when no long needed.
+   * @deprecated
+   */
+  export type BackCompatTestDefn =
+    Omit<Smokehouse.TestDfn, 'expectations'> &
+    {expectations: Smokehouse.ExpectedRunnerResult | Array<Smokehouse.ExpectedRunnerResult>}
+
   export type LighthouseRunner =
     (url: string, configJson?: LH.Config.Json, runnerOptions?: {isDebug?: boolean}) => Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>;
 


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/frontends/back-compat-util.js b/lighthouse-cli/test/smokehouse/frontends/back-compat-util.js
new file mode 100644
index 000000000..2bf1bf5db
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/frontends/back-compat-util.js
@@ -0,0 +1,42 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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';
+
+/**
+ * COMPAT: update from the old TestDefn format (array of `expectations` per
+ * definition) to the new format (single `expectations` per def), doing our best
+ * generating some unique IDs.
+ * TODO: remove in Lighthouse 9+ once PubAds (and others?) are updated.
+ * @see https://github.com/GoogleChrome/lighthouse/issues/11950
+ * @param {ReadonlyArray<Smokehouse.BackCompatTestDefn>} allTestDefns
+ * @return {Array<Smokehouse.TestDfn>}
+ */
+function updateTestDefnFormat(allTestDefns) {
+  const expandedTestDefns = allTestDefns.map(testDefn => {
+    if (Array.isArray(testDefn.expectations)) {
+      // Create a testDefn per expectation.
+      return testDefn.expectations.map((expectations, index) => {
+        return {
+          ...testDefn,
+          id: `${testDefn.id}-${index}`,
+          expectations,
+        };
+      });
+    } else {
+      // New object to make tsc happy.
+      return {
+        ...testDefn,
+        expectations: testDefn.expectations,
+      };
+    }
+  });
+
+  return expandedTestDefns.flat();
+}
+
+module.exports = {
+  updateTestDefnFormat,
+};
diff --git a/lighthouse-cli/test/smokehouse/frontends/lib.js b/lighthouse-cli/test/smokehouse/frontends/lib.js
index fcee96964..1f77e4ca4 100644
--- a/lighthouse-cli/test/smokehouse/frontends/lib.js
+++ b/lighthouse-cli/test/smokehouse/frontends/lib.js
@@ -16,6 +16,7 @@
 const cloneDeep = require('lodash.clonedeep');
 const smokeTests = require('../test-definitions/core-tests.js');
 const {runSmokehouse} = require('../smokehouse.js');
+const {updateTestDefnFormat} = require('./back-compat-util.js');
 
 /**
  * @param {Smokehouse.SmokehouseLibOptions} options
@@ -23,29 +24,23 @@ const {runSmokehouse} = require('../smokehouse.js');
 async function smokehouse(options) {
   const {urlFilterRegex, skip, modify, ...smokehouseOptions} = options;
 
-  const clonedTests = cloneDeep(smokeTests);
-  const modifiedTests = clonedTests.map(test => {
-    const modifiedExpectations = [];
-    for (const expected of test.expectations) {
-      if (urlFilterRegex && !expected.lhr.requestedUrl.match(urlFilterRegex)) {
-        continue;
-      }
-
-      const reasonToSkip = skip && skip(test, expected);
-      if (reasonToSkip) {
-        console.log(`skipping ${expected.lhr.requestedUrl}: ${reasonToSkip}`);
-        continue;
-      }
-
-      modify && modify(test, expected);
-      modifiedExpectations.push(expected);
+  const updatedCoreTests = updateTestDefnFormat(smokeTests);
+  const clonedTests = cloneDeep(updatedCoreTests);
+  const modifiedTests = [];
+  for (const test of clonedTests) {
+    if (urlFilterRegex && !test.expectations.lhr.requestedUrl.match(urlFilterRegex)) {
+      continue;
     }
 
-    return {
-      ...test,
-      expectations: modifiedExpectations,
-    };
-  }).filter(test => test.expectations.length > 0);
+    const reasonToSkip = skip && skip(test, test.expectations);
+    if (reasonToSkip) {
+      console.log(`skipping ${test.expectations.lhr.requestedUrl}: ${reasonToSkip}`);
+      continue;
+    }
+
+    modify && modify(test, test.expectations);
+    modifiedTests.push(test);
+  }
 
   return runSmokehouse(modifiedTests, smokehouseOptions);
 }
diff --git a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
index 33deec918..4b5c9070b 100644
--- a/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
+++ b/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
@@ -18,6 +18,7 @@ const cloneDeep = require('lodash.clonedeep');
 const yargs = require('yargs');
 const log = require('lighthouse-logger');
 const {runSmokehouse} = require('../smokehouse.js');
+const {updateTestDefnFormat} = require('./back-compat-util.js');
 
 const coreTestDefnsPath = path.join(__dirname, '../test-definitions/core-tests.js');
 
@@ -46,12 +47,18 @@ function getDefinitionsToRun(allTestDefns, requestedIds, {invertMatch}) {
     console.log('Running ALL smoketests. Equivalent to:');
     console.log(usage);
   } else {
-    smokes = allTestDefns.filter(test => invertMatch !== requestedIds.includes(test.id));
+    smokes = allTestDefns.filter(test => {
+      // Include all tests that *include* requested id.
+      // e.g. a requested 'pwa' will match 'pwa-airhorner', 'pwa-caltrain', etc
+      let isRequested = requestedIds.some(requestedId => test.id.includes(requestedId));
+      if (invertMatch) isRequested = !isRequested;
+      return isRequested;
+    });
     console.log(`Running ONLY smoketests for: ${smokes.map(t => t.id).join(' ')}\n`);
   }
 
   const unmatchedIds = requestedIds.filter(requestedId => {
-    return !allTestDefns.map(t => t.id).includes(requestedId);
+    return !allTestDefns.map(t => t.id).some(id => id.includes(requestedId));
   });
   if (unmatchedIds.length) {
     console.log(log.redify(`Smoketests not found for: ${unmatchedIds.join(' ')}`));
@@ -80,23 +87,21 @@ function pruneExpectedNetworkRequests(testDefns, takeNetworkRequestUrls) {
 
   const clonedDefns = cloneDeep(testDefns);
   for (const {id, expectations, runSerially} of clonedDefns) {
-    for (const expectation of expectations) {
-      if (!runSerially && expectation.networkRequests) {
-        throw new Error(`'${id}' must be set to 'runSerially: true' to assert 'networkRequests'`);
-      }
+    if (!runSerially && expectations.networkRequests) {
+      throw new Error(`'${id}' must be set to 'runSerially: true' to assert 'networkRequests'`);
+    }
 
-      if (pruneNetworkRequests && expectation.networkRequests) {
-        // eslint-disable-next-line max-len
-        const msg = `'networkRequests' cannot be asserted in test '${id}'. They should only be asserted on tests from an in-process server`;
-        if (process.env.CI) {
-          // If we're in CI, we require any networkRequests expectations to be asserted.
-          throw new Error(msg);
-        }
-
-        console.warn(log.redify('Warning:'),
-            `${msg}. Pruning expectation: ${JSON.stringify(expectation.networkRequests)}`);
-        expectation.networkRequests = undefined;
+    if (pruneNetworkRequests && expectations.networkRequests) {
+      // eslint-disable-next-line max-len
+      const msg = `'networkRequests' cannot be asserted in test '${id}'. They should only be asserted on tests from an in-process server`;
+      if (process.env.CI) {
+        // If we're in CI, we require any networkRequests expectations to be asserted.
+        throw new Error(msg);
       }
+
+      console.warn(log.redify('Warning:'),
+          `${msg}. Pruning expectation: ${JSON.stringify(expectations.networkRequests)}`);
+      expectations.networkRequests = undefined;
     }
   }
 
@@ -166,7 +171,8 @@ async function begin() {
   let testDefnPath = argv.testsPath || coreTestDefnsPath;
   testDefnPath = path.resolve(process.cwd(), testDefnPath);
   const requestedTestIds = argv._;
-  const allTestDefns = require(testDefnPath);
+  const rawTestDefns = require(testDefnPath);
+  const allTestDefns = updateTestDefnFormat(rawTestDefns);
   const invertMatch = argv.invertMatch;
   const testDefns = getDefinitionsToRun(allTestDefns, requestedTestIds, {invertMatch});
 
diff --git a/lighthouse-cli/test/smokehouse/lib/concurrent-mapper.js b/lighthouse-cli/test/smokehouse/lib/concurrent-mapper.js
index ff5d48b25..72b307b9f 100644
--- a/lighthouse-cli/test/smokehouse/lib/concurrent-mapper.js
+++ b/lighthouse-cli/test/smokehouse/lib/concurrent-mapper.js
@@ -106,6 +106,21 @@ class ConcurrentMapper {
 
     return Promise.all(result);
   }
+
+  /**
+   * Runs `fn` concurrent to other operations in the pool, at a max of
+   * `concurrency` at a time across all callers on this instance. Default
+   * `concurrency` limit is `Infinity`.
+   * @template U
+   * @param {() => Promise<U>} fn
+   * @param {{concurrency: number}} [options]
+   * @return {Promise<U>}
+   */
+  async runInPool(fn, options = {concurrency: Infinity}) {
+    // Let pooledMap handle the pool management for the cost of boxing a fake `value`.
+    const result = await this.pooledMap([''], fn, options);
+    return result[0];
+  }
 }
 
 module.exports = ConcurrentMapper;
diff --git a/lighthouse-cli/test/smokehouse/report-assert.js b/lighthouse-cli/test/smokehouse/report-assert.js
index 9ce5c8560..aead6bd6f 100644
--- a/lighthouse-cli/test/smokehouse/report-assert.js
+++ b/lighthouse-cli/test/smokehouse/report-assert.js
@@ -333,15 +333,6 @@ function reportAssertion(localConsole, assertion) {
   RegExp.prototype.toJSON = _toJSON;
 }
 
-/**
- * @param {number} count
- * @return {string}
- */
-function assertLogString(count) {
-  const plural = count === 1 ? '' : 's';
-  return `${count} assertion${plural}`;
-}
-
 /**
  * Log all the comparisons between actual and expected test results, then print
  * summary. Returns count of passed and failed tests.
@@ -371,17 +362,6 @@ function report(actual, expected, reportOptions = {}) {
     }
   });
 
-  const correctStr = assertLogString(correctCount);
-  const colorFn = correctCount === 0 ? log.redify : log.greenify;
-  localConsole.log(`  Correctly passed ${colorFn(correctStr)}`);
-
-  if (failedCount) {
-    const failedString = assertLogString(failedCount);
-    const failedColorFn = failedCount === 0 ? log.greenify : log.redify;
-    localConsole.log(`  Failed ${failedColorFn(failedString)}`);
-  }
-  localConsole.write('\n');
-
   return {
     passed: correctCount,
     failed: failedCount,
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index e2d09b933..990c69260 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -28,8 +28,8 @@ const DEFAULT_RETRIES = 0;
 /**
  * @typedef SmokehouseResult
  * @property {string} id
- * @property {boolean} success
- * @property {Array<{passed: number, failed: number, log: string}>} expectationResults
+ * @property {number} passed
+ * @property {number} failed
  */
 
 /**
@@ -49,38 +49,32 @@ async function runSmokehouse(smokeTestDefns, smokehouseOptions) {
   assertPositiveInteger('jobs', jobs);
   assertNonNegativeInteger('retries', retries);
 
-  // Run each testDefn's tests in parallel based on the concurrencyLimit.
+  // Run each testDefn in parallel based on the concurrencyLimit.
   const concurrentMapper = new ConcurrentMapper();
-  const smokePromises = [];
-  for (const testDefn of smokeTestDefns) {
-    // If defn is set to `runSerially`, we'll run its tests in succession, not parallel.
+
+  const testOptions = {isDebug, retries, lighthouseRunner, takeNetworkRequestUrls};
+  const smokePromises = smokeTestDefns.map(testDefn => {
+    // If defn is set to `runSerially`, we'll run it in succession with other tests, not parallel.
     const concurrency = testDefn.runSerially ? 1 : jobs;
-    const options = {concurrency, lighthouseRunner, retries, isDebug, takeNetworkRequestUrls};
-    const result = runSmokeTestDefn(concurrentMapper, testDefn, options);
-    smokePromises.push(result);
-  }
+    return concurrentMapper.runInPool(() => runSmokeTest(testDefn, testOptions), {concurrency});
+  });
   const testResults = await Promise.all(smokePromises);
 
+  // Print final summary.
   let passingCount = 0;
   let failingCount = 0;
   for (const testResult of testResults) {
-    for (const expectationResult of testResult.expectationResults) {
-      passingCount += expectationResult.passed;
-      failingCount += expectationResult.failed;
-    }
-  }
-  if (passingCount) {
-    console.log(log.greenify(`${passingCount} expectations passing`));
-  }
-  if (failingCount) {
-    console.log(log.redify(`${failingCount} expectations failing`));
+    passingCount += testResult.passed;
+    failingCount += testResult.failed;
   }
+  if (passingCount) console.log(log.greenify(`${getAssertionLog(passingCount)} passing in total`));
+  if (failingCount) console.log(log.redify(`${getAssertionLog(failingCount)} failing in total`));
 
-  // Print and fail if there were failing tests.
-  const failingDefns = testResults.filter(result => !result.success);
+  // Print id(s) and fail if there were failing tests.
+  const failingDefns = testResults.filter(result => result.failed);
   if (failingDefns.length) {
     const testNames = failingDefns.map(d => d.id).join(', ');
-    console.error(log.redify(`We have ${failingDefns.length} failing smoketests: ${testNames}`));
+    console.error(log.redify(`We have ${failingDefns.length} failing smoketest(s): ${testNames}`));
     return {success: false, testResults};
   }
 
@@ -106,100 +100,32 @@ function assertNonNegativeInteger(loggableName, value) {
   }
 }
 
-/**
- * Run all the smoke tests specified, displaying output from each, in order,
- * once all are finished.
- * @param {ConcurrentMapper} concurrentMapper
- * @param {Smokehouse.TestDfn} smokeTestDefn
- * @param {{concurrency: number, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, isDebug?: boolean, takeNetworkRequestUrls?: () => string[]}} defnOptions
- * @return {Promise<SmokehouseResult>}
- */
-async function runSmokeTestDefn(concurrentMapper, smokeTestDefn, defnOptions) {
-  const {id, config: configJson, expectations} = smokeTestDefn;
-  const {concurrency, lighthouseRunner, retries, isDebug, takeNetworkRequestUrls} = defnOptions;
-
-  const individualTests = expectations.map(expectation => {
-    return {
-      requestedUrl: expectation.lhr.requestedUrl,
-      configJson,
-      expectation,
-      lighthouseRunner,
-      retries,
-      isDebug,
-      takeNetworkRequestUrls,
-    };
-  });
-
-  // Loop sequentially over expectations, comparing against Lighthouse run, and
-  // reporting result.
-  const results = await concurrentMapper.pooledMap(individualTests, (test, index) => {
-    if (index === 0) console.log(`${purpleify(id)} smoketest starting…`);
-    return runSmokeTest(test);
-  }, {concurrency});
-
-  console.log(`\n${purpleify(id)} smoketest results:`);
-
-  let passingTestCount = 0;
-  let failingTestCount = 0;
-  for (const result of results) {
-    if (result.failed) {
-      failingTestCount++;
-    } else {
-      passingTestCount++;
-    }
-
-    console.log(result.log);
-  }
-
-  console.log(`${purpleify(id)} smoketest complete.`);
-  if (passingTestCount) {
-    console.log(log.greenify(`  ${passingTestCount} test(s) passing`));
-  }
-  if (failingTestCount) {
-    console.log(log.redify(`  ${failingTestCount} test(s) failing`));
-  }
-  console.log(''); // extra line break
-
-  return {
-    id,
-    success: failingTestCount === 0,
-    expectationResults: results,
-  };
-}
-
 /** @param {string} str */
 function purpleify(str) {
   return `${log.purple}${str}${log.reset}`;
 }
 
 /**
- * Run Lighthouse in the selected runner. Returns `log`` for logging once
- * all tests in a defn are complete.
- * @param {{requestedUrl: string, configJson?: LH.Config.Json, expectation: Smokehouse.ExpectedRunnerResult, lighthouseRunner: Smokehouse.LighthouseRunner, retries: number, isDebug?: boolean, takeNetworkRequestUrls?: () => string[]}} testOptions
- * @return {Promise<{passed: number, failed: number, log: string}>}
+ * Run Lighthouse in the selected runner.
+ * @param {Smokehouse.TestDfn} smokeTestDefn
+ * @param {{isDebug?: boolean, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, takeNetworkRequestUrls?: () => string[]}} testOptions
+ * @return {Promise<SmokehouseResult>}
  */
-async function runSmokeTest(testOptions) {
-  // Use a buffered LocalConsole to keep logged output so it's not interleaved
-  // with other currently running tests.
-  const localConsole = new LocalConsole();
-  const {
-    requestedUrl,
-    configJson,
-    expectation,
-    lighthouseRunner,
-    retries,
-    isDebug,
-    takeNetworkRequestUrls,
-  } = testOptions;
+async function runSmokeTest(smokeTestDefn, testOptions) {
+  const {id, config: configJson, expectations} = smokeTestDefn;
+  const {lighthouseRunner, retries, isDebug, takeNetworkRequestUrls} = testOptions;
+  const requestedUrl = expectations.lhr.requestedUrl;
+
+  console.log(`${purpleify(id)} smoketest starting…`);
 
   // Rerun test until there's a passing result or retries are exhausted to prevent flakes.
   let result;
   let report;
+  const bufferedConsole = new LocalConsole();
+  bufferedConsole.log(`\n${purpleify(id)}: testing '${requestedUrl}'…`);
   for (let i = 0; i <= retries; i++) {
-    if (i === 0) {
-      localConsole.log(`Doing a run of '${requestedUrl}'...`);
-    } else {
-      localConsole.log(`Retrying run (${i} out of ${retries} retries)...`);
+    if (i !== 0) {
+      bufferedConsole.log(`  Retrying run (${i} out of ${retries} retries)…`);
     }
 
     // Run Lighthouse.
@@ -212,39 +138,47 @@ async function runSmokeTest(testOptions) {
       // Clear the network requests so that when we retry, we don't see duplicates.
       if (takeNetworkRequestUrls) takeNetworkRequestUrls();
 
-      logChildProcessError(localConsole, e);
+      logChildProcessError(bufferedConsole, e);
       continue; // Retry, if possible.
     }
 
     // Assert result.
-    report = getAssertionReport(result, expectation, {isDebug});
+    report = getAssertionReport(result, expectations, {isDebug});
     if (report.failed) {
-      localConsole.log(`${report.failed} assertion(s) failed.`);
+      bufferedConsole.log(`  ${getAssertionLog(report.failed)} failed.`);
       continue; // Retry, if possible.
     }
 
     break; // Passing result, no need to retry.
   }
 
+  bufferedConsole.log(`  smoketest results:`);
+
   // Write result log if we have one.
-  if (result) {
-    localConsole.write(result.log);
-  }
+  if (result) bufferedConsole.write(result.log);
+
+  // If there's not an assertion report, just report the whole thing as a single failure.
+  if (report) bufferedConsole.write(report.log);
+  const passed = report ? report.passed : 0;
+  const failed = report ? report.failed : 1;
 
-  // Without an assertion report, not much detail to share but a failure.
-  if (!report) {
-    return {
-      passed: 0,
-      failed: 1,
-      log: localConsole.getLog(),
-    };
+  const correctStr = getAssertionLog(passed);
+  const colorFn = passed === 0 ? log.redify : log.greenify;
+  bufferedConsole.log(`  Correctly passed ${colorFn(correctStr)}`);
+
+  if (failed) {
+    const failedString = getAssertionLog(failed);
+    bufferedConsole.log(`  Failed ${log.redify(failedString)}`);
   }
+  bufferedConsole.log(`${purpleify(id)} smoketest complete.`);
+
+  // Log now so right after finish, but all at once so not interleaved with other tests.
+  console.log(bufferedConsole.getLog());
 
-  localConsole.write(report.log);
   return {
-    passed: report.passed,
-    failed: report.failed,
-    log: localConsole.getLog(),
+    id,
+    passed,
+    failed,
   };
 }
 
@@ -262,6 +196,15 @@ function logChildProcessError(localConsole, err) {
   localConsole.log(log.redify('Error: ') + err.message);
 }
 
+/**
+ * @param {number} count
+ * @return {string}
+ */
+function getAssertionLog(count) {
+  const plural = count === 1 ? '' : 's';
+  return `${count} assertion${plural}`;
+}
+
 module.exports = {
   runSmokehouse,
 };
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
index 7ab882157..4ca442682 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-/** @type {ReadonlyArray<Smokehouse.TestDfn>} */
+/** @type {ReadonlyArray<Smokehouse.BackCompatTestDefn>} */
 const smokeTests = [{
   id: 'a11y',
   expectations: require('./a11y/expectations.js'),
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index 696febf8d..6114a8717 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -130,7 +130,7 @@ describe('Fraggle Rock API', () => {
         notApplicableAudits,
       } = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`59`);
+      expect(auditResults.length).toMatchInlineSnapshot(`49`);
 
       expect(notApplicableAudits.length).toMatchInlineSnapshot(`7`);
       expect(notApplicableAudits.map(audit => audit.id)).not.toContain('server-response-time');
@@ -172,7 +172,7 @@ 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(`59`);
+      expect(auditResults.length).toMatchInlineSnapshot(`49`);
 
       expect(notApplicableAudits.length).toMatchInlineSnapshot(`9`);
       expect(notApplicableAudits.map(audit => audit.id)).toContain('server-response-time');
diff --git a/lighthouse-core/test/fraggle-rock/config/config-test.js b/lighthouse-core/test/fraggle-rock/config/config-test.js
index 169458e1c..eaad3086f 100644
--- a/lighthouse-core/test/fraggle-rock/config/config-test.js
+++ b/lighthouse-core/test/fraggle-rock/config/config-test.js
@@ -103,8 +103,8 @@ describe('Fraggle Rock Config', () => {
   it('should set default properties on navigations', () => {
     gatherMode = 'navigation';
     const configJson = {
-      artifacts: [],
-      navigations: [{id: 'default'}],
+      artifacts: [{id: 'Accessibility', gatherer: 'accessibility'}],
+      navigations: [{id: 'default', artifacts: ['Accessibility']}],
     };
     const {config} = initializeConfig(configJson, {gatherMode});
 
@@ -113,7 +113,7 @@ describe('Fraggle Rock Config', () => {
         {
           id: 'default',
           blankPage: 'about:blank',
-          artifacts: [],
+          artifacts: [{id: 'Accessibility', gatherer: {path: 'accessibility'}}],
           disableThrottling: false,
           networkQuietThresholdMs: 0,
           cpuQuietThresholdMs: 0,
@@ -139,6 +139,23 @@ describe('Fraggle Rock Config', () => {
     });
   });
 
+  it('should filter configuration by only/skip filters', () => {
+    const {config} = initializeConfig(undefined, {
+      gatherMode: 'navigation',
+      settingsOverrides: {
+        onlyAudits: ['color-contrast'],
+        onlyCategories: ['seo'],
+        skipAudits: ['structured-data', 'robots-txt', 'largest-contentful-paint'],
+      },
+    });
+
+    const auditIds = (config.audits || []).map(audit => audit.implementation.meta.id);
+    expect(auditIds).toContain('color-contrast'); // from onlyAudits
+    expect(auditIds).toContain('document-title'); // from onlyCategories
+    expect(auditIds).not.toContain('first-contentful-paint'); // from onlyCategories
+    expect(auditIds).not.toContain('robots-txt'); // from skipAudits
+  });
+
   describe('resolveArtifactDependencies', () => {
     /** @type {LH.Gatherer.FRGathererInstance} */
     let dependencyGatherer;
@@ -248,8 +265,6 @@ describe('Fraggle Rock Config', () => {
   it.todo('should support extension');
   it.todo('should support plugins');
   it.todo('should adjust default pass options for throttling method');
-  it.todo('should filter configuration by inclusive settings');
-  it.todo('should filter configuration by exclusive settings');
   it.todo('should validate audit/gatherer interdependencies');
   it.todo('should validate gatherers do not support all 3 modes');
 });
diff --git a/lighthouse-core/test/fraggle-rock/config/filters-test.js b/lighthouse-core/test/fraggle-rock/config/filters-test.js
index 107b2b1fd..f82dee799 100644
--- a/lighthouse-core/test/fraggle-rock/config/filters-test.js
+++ b/lighthouse-core/test/fraggle-rock/config/filters-test.js
@@ -7,8 +7,9 @@
 
 const BaseAudit = require('../../../audits/audit.js');
 const BaseGatherer = require('../../../fraggle-rock/gather/base-gatherer.js');
-const {defaultSettings} = require('../../../config/constants.js');
+const {defaultSettings, defaultNavigationConfig} = require('../../../config/constants.js');
 const filters = require('../../../fraggle-rock/config/filters.js');
+const {initializeConfig} = require('../../../fraggle-rock/config/config.js');
 
 /* eslint-env jest */
 
@@ -23,21 +24,10 @@ describe('Fraggle Rock Config Filtering', () => {
     {id: 'Timespan', gatherer: {instance: timespanGatherer}},
   ];
 
-  describe('filterArtifactsByGatherMode', () => {
-    it('should handle null', () => {
-      expect(filters.filterArtifactsByGatherMode(null, 'snapshot')).toBe(null);
-    });
-
-    it('should filter to the correct mode', () => {
-      expect(filters.filterArtifactsByGatherMode(artifacts, 'snapshot')).toEqual([
-        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
-      ]);
-
-      expect(filters.filterArtifactsByGatherMode(artifacts, 'timespan')).toEqual([
-        {id: 'Timespan', gatherer: {instance: timespanGatherer}},
-      ]);
-    });
-  });
+  const navigationArtifacts = [
+    ...artifacts,
+    {id: 'Snapshot2', gatherer: {instance: snapshotGatherer}},
+  ];
 
   const auditMeta = {title: '', description: ''};
   class SnapshotAudit extends BaseAudit {
@@ -83,6 +73,140 @@ describe('Fraggle Rock Config Filtering', () => {
     options: {},
   }));
 
+  /** @type {Array<LH.Config.NavigationDefn>} */
+  const navigations = [
+    {
+      ...defaultNavigationConfig,
+      id: 'firstPass',
+      artifacts: [
+        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
+        {id: 'Timespan', gatherer: {instance: timespanGatherer}},
+      ],
+    },
+    {
+      ...defaultNavigationConfig,
+      id: 'secondPass',
+      artifacts: [
+        {id: 'Snapshot2', gatherer: {instance: snapshotGatherer}},
+      ],
+    },
+  ];
+
+  describe('filterArtifactsByGatherMode', () => {
+    it('should handle null', () => {
+      expect(filters.filterArtifactsByGatherMode(null, 'snapshot')).toBe(null);
+    });
+
+    it('should filter to the correct mode', () => {
+      expect(filters.filterArtifactsByGatherMode(artifacts, 'snapshot')).toEqual([
+        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
+      ]);
+
+      expect(filters.filterArtifactsByGatherMode(artifacts, 'timespan')).toEqual([
+        {id: 'Timespan', gatherer: {instance: timespanGatherer}},
+      ]);
+    });
+  });
+
+  describe('filterArtifactsByAvailableAudits', () => {
+    it('should handle null artifacts', () => {
+      expect(filters.filterArtifactsByAvailableAudits(null, audits)).toBe(null);
+    });
+
+    it('should handle null audits', () => {
+      expect(filters.filterArtifactsByAvailableAudits(artifacts, null)).toBe(artifacts);
+    });
+
+    it('should filter to used artifacts', () => {
+      expect(filters.filterArtifactsByAvailableAudits(artifacts, [
+        {implementation: SnapshotAudit, options: {}},
+      ])).toEqual([
+        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
+      ]);
+
+      expect(filters.filterArtifactsByAvailableAudits(artifacts, [
+        {implementation: NavigationAudit, options: {}},
+      ])).toEqual([
+        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
+        {id: 'Timespan', gatherer: {instance: timespanGatherer}},
+      ]);
+    });
+
+    it('should handle transitive dependencies', () => {
+      const baseSymbol = Symbol('baseGatherer');
+      const base = new BaseGatherer();
+      base.meta = {supportedModes: ['snapshot'], symbol: baseSymbol};
+
+      const dependentSymbol = Symbol('dependentGatherer');
+      /** @type {LH.Gatherer.FRGathererInstance<'Accessibility'>} */
+      const dependent = Object.assign(new BaseGatherer(), {
+        meta: {
+          supportedModes: ['snapshot'],
+          dependencies: {Accessibility: baseSymbol},
+          symbol: dependentSymbol,
+        },
+      });
+
+      /** @type {LH.Gatherer.FRGathererInstance<'Accessibility'>} */
+      const dependentsDependent = Object.assign(new BaseGatherer(), {
+        meta: {
+          supportedModes: ['snapshot'],
+          dependencies: {Accessibility: dependentSymbol},
+        },
+      });
+
+
+      /** @type {LH.Config.AnyArtifactDefn[]} */
+      const transitiveArtifacts = [
+        {id: 'DependencysDependency', gatherer: {instance: base}},
+        {
+          id: 'SnapshotDependency',
+          gatherer: {instance: dependent},
+          dependencies: {Accessibility: {id: 'DependencysDependency'}},
+        },
+        {
+          id: 'Snapshot',
+          gatherer: {instance: dependentsDependent},
+          dependencies: {Accessibility: {id: 'SnapshotDependency'}},
+        },
+      ];
+
+      expect(filters.filterArtifactsByAvailableAudits(transitiveArtifacts, [
+        {implementation: SnapshotAudit, options: {}},
+      ])).toMatchObject([
+        {id: 'DependencysDependency', gatherer: {instance: base}},
+        {id: 'SnapshotDependency', gatherer: {instance: dependent}},
+        {id: 'Snapshot', gatherer: {instance: dependentsDependent}},
+      ]);
+    });
+  });
+
+  describe('filterNavigationsByAvailableArtifacts', () => {
+    it('should handle null', () => {
+      expect(filters.filterNavigationsByAvailableArtifacts(null, [])).toBe(null);
+    });
+
+    it('should filter out entire navigations', () => {
+      const partialArtifacts = [{id: 'Timespan', gatherer: {instance: snapshotGatherer}}];
+      const filtered = filters.filterNavigationsByAvailableArtifacts(navigations, partialArtifacts);
+      expect(filtered).toMatchObject([
+        {id: 'firstPass', artifacts: [{id: 'Timespan'}]},
+      ]);
+    });
+
+    it('should filter within navigation', () => {
+      const partialArtifacts = [
+        {id: 'Snapshot', gatherer: {instance: snapshotGatherer}},
+        {id: 'Snapshot2', gatherer: {instance: snapshotGatherer}},
+      ];
+      const filtered = filters.filterNavigationsByAvailableArtifacts(navigations, partialArtifacts);
+      expect(filtered).toMatchObject([
+        {id: 'firstPass', artifacts: [{id: 'Snapshot'}]},
+        {id: 'secondPass', artifacts: [{id: 'Snapshot2'}]},
+      ]);
+    });
+  });
+
   describe('filterAuditsByAvailableArtifacts', () => {
     it('should handle null', () => {
       expect(filters.filterAuditsByAvailableArtifacts(null, [])).toBe(null);
@@ -219,13 +343,15 @@ describe('Fraggle Rock Config Filtering', () => {
     it('should filter the entire config', () => {
       const config = {
         artifacts,
-        navigations: null,
+        navigations,
         audits,
         categories,
         groups: null,
         settings: defaultSettings,
       };
+
       expect(filters.filterConfigByGatherMode(config, 'snapshot')).toMatchObject({
+        navigations,
         artifacts: [{id: 'Snapshot'}],
         audits: [{implementation: SnapshotAudit}, {implementation: ManualAudit}],
         categories: {
@@ -236,4 +362,125 @@ describe('Fraggle Rock Config Filtering', () => {
       });
     });
   });
+
+  describe('filterConfigByExplicitFilters', () => {
+    /** @type {LH.Config.FRConfig} */
+    let config;
+
+    beforeEach(() => {
+      config = {
+        artifacts: navigationArtifacts,
+        navigations,
+        audits,
+        categories,
+        groups: null,
+        settings: defaultSettings,
+      };
+    });
+
+    it('should filter via onlyAudits', () => {
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyAudits: ['snapshot'],
+        onlyCategories: null,
+        skipAudits: null,
+      });
+
+      expect(filtered).toMatchObject({
+        artifacts: [{id: 'Snapshot'}],
+        audits: [{implementation: SnapshotAudit}],
+        categories: {
+          snapshot: {},
+          mixed: {auditRefs: [{id: 'snapshot'}]},
+        },
+      });
+    });
+
+    it('should filter via skipAudits', () => {
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyAudits: null,
+        onlyCategories: null,
+        skipAudits: ['snapshot', 'navigation'],
+      });
+      expect(filtered).toMatchObject({
+        artifacts: [{id: 'Timespan'}],
+        audits: [{implementation: TimespanAudit}, {implementation: ManualAudit}],
+        categories: {
+          timespan: {},
+          mixed: {auditRefs: [{id: 'timespan'}]},
+        },
+      });
+    });
+
+    it('should filter via onlyCategories', () => {
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyAudits: null,
+        onlyCategories: ['timespan'],
+        skipAudits: null,
+      });
+      if (!filtered.categories) throw new Error('Failed to keep any categories');
+      expect(Object.keys(filtered.categories)).toEqual(['timespan']);
+      expect(filtered).toMatchObject({
+        artifacts: [{id: 'Timespan'}],
+        audits: [{implementation: TimespanAudit}],
+        categories: {
+          timespan: {},
+        },
+      });
+    });
+
+    it('should filter via a combination of filters', () => {
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyCategories: ['mixed'],
+        onlyAudits: ['snapshot', 'timespan'],
+        skipAudits: ['timespan', 'navigation'],
+      });
+      expect(filtered).toMatchObject({
+        artifacts: [{id: 'Snapshot'}],
+        audits: [{implementation: SnapshotAudit}],
+        categories: {
+          mixed: {},
+        },
+      });
+    });
+
+    it('should filter out audits and artifacts not in the categories by default', () => {
+      config = {
+        ...config,
+        audits: [
+          ...audits,
+          {implementation: NavigationOnlyAudit, options: {}},
+        ],
+      };
+
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyAudits: null,
+        onlyCategories: null,
+        skipAudits: null,
+      });
+      expect(filtered).toMatchObject({
+        navigations: [{id: 'firstPass'}],
+        artifacts: [{id: 'Snapshot'}, {id: 'Timespan'}],
+        audits: [
+          {implementation: SnapshotAudit},
+          {implementation: TimespanAudit},
+          {implementation: NavigationAudit},
+          {implementation: ManualAudit},
+        ],
+      });
+    });
+
+    it('should preserve full-page-screenshot', () => {
+      config = initializeConfig(undefined, {gatherMode: 'navigation'}).config;
+
+      const filtered = filters.filterConfigByExplicitFilters(config, {
+        onlyAudits: ['color-contrast'],
+        onlyCategories: null,
+        skipAudits: null,
+      });
+
+      if (!filtered.audits) throw new Error('No audits produced');
+      const auditIds = filtered.audits.map(audit => audit.implementation.meta.id);
+      expect(auditIds).toEqual(['full-page-screenshot', 'color-contrast']);
+    });
+  });
 });
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 5cb204f34..efd07ae10 100644
--- a/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
@@ -150,7 +150,12 @@ describe('NavigationRunner', () => {
       config = initializeConfig(
         {
           ...config,
-          navigations: [{id: 'default'}, {id: 'second'}, {id: 'third'}, {id: 'fourth'}],
+          navigations: [
+            {id: 'default', artifacts: ['FontSize']},
+            {id: 'second', artifacts: ['ConsoleMessages']},
+            {id: 'third', artifacts: ['ViewportDimensions']},
+            {id: 'fourth', artifacts: ['AnchorElements']},
+          ],
         },
         {gatherMode: 'navigation'}
       ).config;