lighthouse 9.5.0-dev.20230110 → 9.5.0-dev.20230112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/cli/bin.js +6 -6
  2. package/cli/run.js +1 -1
  3. package/cli/test/smokehouse/config/exclusions.js +5 -0
  4. package/cli/test/smokehouse/core-tests.js +14 -14
  5. package/cli/test/smokehouse/lighthouse-runners/bundle.js +9 -9
  6. package/cli/test/smokehouse/lighthouse-runners/cli.js +7 -7
  7. package/cli/test/smokehouse/lighthouse-runners/devtools.js +3 -3
  8. package/cli/test/smokehouse/readme.md +1 -1
  9. package/cli/test/smokehouse/smokehouse.js +13 -13
  10. package/core/audits/bf-cache.js +120 -0
  11. package/core/audits/installable-manifest.js +1 -1
  12. package/core/audits/seo/is-crawlable.js +114 -42
  13. package/core/config/config-helpers.js +11 -11
  14. package/core/config/config-plugin.js +2 -2
  15. package/core/config/config.js +15 -15
  16. package/core/config/default-config.js +8 -3
  17. package/core/config/desktop-config.js +1 -1
  18. package/core/config/experimental-config.js +1 -1
  19. package/core/config/full-config.js +1 -1
  20. package/core/config/lr-desktop-config.js +1 -1
  21. package/core/config/lr-mobile-config.js +1 -1
  22. package/core/config/perf-config.js +1 -1
  23. package/core/config/validation.js +4 -4
  24. package/core/gather/driver/execution-context.js +2 -2
  25. package/core/gather/driver/network-monitor.js +2 -2
  26. package/core/gather/driver/wait-for-condition.js +9 -2
  27. package/core/gather/gatherers/bf-cache-failures.js +21 -3
  28. package/core/gather/navigation-runner.js +1 -1
  29. package/core/gather/snapshot-runner.js +1 -1
  30. package/core/gather/timespan-runner.js +1 -1
  31. package/core/index.js +10 -10
  32. package/core/legacy/config/config.js +23 -23
  33. package/core/legacy/config/legacy-default-config.js +1 -1
  34. package/core/lib/{bfcache-strings.js → bf-cache-strings.js} +1 -0
  35. package/core/user-flow.js +9 -9
  36. package/core/util.cjs +13 -3
  37. package/dist/report/bundle.esm.js +13 -3
  38. package/dist/report/flow.js +1 -1
  39. package/dist/report/standalone.js +1 -1
  40. package/package.json +1 -1
  41. package/report/renderer/util.js +13 -3
  42. package/report/test/generator/report-generator-test.js +1 -1
  43. package/report/test/renderer/util-test.js +2 -0
  44. package/shared/localization/locales/en-US.json +141 -114
  45. package/shared/localization/locales/en-XL.json +141 -114
  46. package/tsconfig.json +1 -0
  47. package/types/config.d.ts +18 -18
  48. package/types/smokehouse.d.ts +2 -2
  49. package/types/user-flow.d.ts +1 -1
@@ -10,6 +10,14 @@ import {Audit} from '../audit.js';
10
10
  import {MainResource} from '../../computed/main-resource.js';
11
11
  import * as i18n from '../../lib/i18n/i18n.js';
12
12
 
13
+ const BOT_USER_AGENTS = new Set([
14
+ undefined,
15
+ 'Googlebot',
16
+ 'bingbot',
17
+ 'DuckDuckBot',
18
+ 'archive.org_bot',
19
+ ]);
20
+
13
21
  const BLOCKLIST = new Set([
14
22
  'noindex',
15
23
  'none',
@@ -48,7 +56,7 @@ function isUnavailable(directive) {
48
56
 
49
57
  /**
50
58
  * Returns true if any of provided directives blocks page from being indexed
51
- * @param {string} directives
59
+ * @param {string} directives assumes no user-agent prefix
52
60
  * @return {boolean}
53
61
  */
54
62
  function hasBlockingDirective(directives) {
@@ -58,16 +66,18 @@ function hasBlockingDirective(directives) {
58
66
  }
59
67
 
60
68
  /**
61
- * Returns true if robots header specifies user agent (e.g. `googlebot: noindex`)
69
+ * Returns user agent if specified in robots header (e.g. `googlebot: noindex`)
62
70
  * @param {string} directives
63
- * @return {boolean}
71
+ * @return {string|undefined}
64
72
  */
65
- function hasUserAgent(directives) {
73
+ function getUserAgentFromHeaderDirectives(directives) {
66
74
  const parts = directives.match(/^([^,:]+):/);
67
75
 
68
76
  // Check if directives are prefixed with `googlebot:`, `googlebot-news:`, `otherbot:`, etc.
69
77
  // but ignore `unavailable_after:` which is a valid directive
70
- return !!parts && parts[1].toLowerCase() !== UNAVAILABLE_AFTER;
78
+ if (!!parts && parts[1].toLowerCase() !== UNAVAILABLE_AFTER) {
79
+ return parts[1];
80
+ }
71
81
  }
72
82
 
73
83
  class IsCrawlable extends Audit {
@@ -85,6 +95,74 @@ class IsCrawlable extends Audit {
85
95
  };
86
96
  }
87
97
 
98
+ /**
99
+ * @param {LH.Artifacts.MetaElement} metaElement
100
+ */
101
+ static handleMetaElement(metaElement) {
102
+ const content = metaElement.content || '';
103
+ if (hasBlockingDirective(content)) {
104
+ return {
105
+ source: {
106
+ ...Audit.makeNodeItem(metaElement.node),
107
+ snippet: `<meta name="${metaElement.name}" content="${content}" />`,
108
+ },
109
+ };
110
+ }
111
+ }
112
+
113
+ /**
114
+ * @param {string|undefined} userAgent
115
+ * @param {LH.Artifacts.NetworkRequest} mainResource
116
+ * @param {LH.Artifacts.MetaElement[]} metaElements
117
+ * @param {import('robots-parser').Robot|undefined} parsedRobotsTxt
118
+ * @param {URL} robotsTxtUrl
119
+ */
120
+ static determineIfCrawlableForUserAgent(userAgent, mainResource, metaElements,
121
+ parsedRobotsTxt, robotsTxtUrl) {
122
+ /** @type {LH.Audit.Details.Table['items']} */
123
+ const blockingDirectives = [];
124
+
125
+ // Prefer a meta element specific to a user agent, fallback to generic 'robots' if not present.
126
+ // https://developers.google.com/search/blog/2007/03/using-robots-meta-tag#directing-a-robots-meta-tag-specifically-at-googlebot
127
+ let meta;
128
+ if (userAgent) meta = metaElements.find(meta => meta.name === userAgent.toLowerCase());
129
+ if (!meta) meta = metaElements.find(meta => meta.name === 'robots');
130
+ if (meta) {
131
+ const blockingDirective = IsCrawlable.handleMetaElement(meta);
132
+ if (blockingDirective) blockingDirectives.push(blockingDirective);
133
+ }
134
+
135
+ for (const header of mainResource.responseHeaders || []) {
136
+ if (header.name.toLowerCase() !== ROBOTS_HEADER) continue;
137
+
138
+ const directiveUserAgent = getUserAgentFromHeaderDirectives(header.value);
139
+ if (directiveUserAgent !== userAgent && directiveUserAgent !== undefined) continue;
140
+
141
+ let directiveWithoutUserAgentPrefix = header.value.trim();
142
+ if (userAgent && header.value.startsWith(`${userAgent}:`)) {
143
+ directiveWithoutUserAgentPrefix = header.value.replace(`${userAgent}:`, '');
144
+ }
145
+ if (!hasBlockingDirective(directiveWithoutUserAgentPrefix)) continue;
146
+
147
+ blockingDirectives.push({source: `${header.name}: ${header.value}`});
148
+ }
149
+
150
+ if (parsedRobotsTxt && !parsedRobotsTxt.isAllowed(mainResource.url, userAgent)) {
151
+ const line = parsedRobotsTxt.getMatchingLineNumber(mainResource.url) || 1;
152
+ blockingDirectives.push({
153
+ source: {
154
+ type: /** @type {const} */ ('source-location'),
155
+ url: robotsTxtUrl.href,
156
+ urlProvider: /** @type {const} */ ('network'),
157
+ line: line - 1,
158
+ column: 0,
159
+ },
160
+ });
161
+ }
162
+
163
+ return blockingDirectives;
164
+ }
165
+
88
166
  /**
89
167
  * @param {LH.Artifacts} artifacts
90
168
  * @param {LH.Audit.Context} context
@@ -92,57 +170,51 @@ class IsCrawlable extends Audit {
92
170
  */
93
171
  static async audit(artifacts, context) {
94
172
  const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
95
- const metaRobots = artifacts.MetaElements.find(meta => meta.name === 'robots');
96
173
  const mainResource = await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);
97
- /** @type {LH.Audit.Details.Table['items']} */
98
- const blockingDirectives = [];
99
174
 
100
- if (metaRobots) {
101
- const metaRobotsContent = metaRobots.content || '';
102
- const isBlocking = hasBlockingDirective(metaRobotsContent);
103
-
104
- if (isBlocking) {
105
- blockingDirectives.push({
106
- source: {
107
- ...Audit.makeNodeItem(metaRobots.node),
108
- snippet: `<meta name="robots" content="${metaRobotsContent}" />`,
109
- },
110
- });
175
+ const robotsTxtUrl = new URL('/robots.txt', mainResource.url);
176
+ const parsedRobotsTxt = artifacts.RobotsTxt.content ?
177
+ robotsParser(robotsTxtUrl.href, artifacts.RobotsTxt.content) :
178
+ undefined;
179
+
180
+ // Only fail if all known bots and generic bots (UserAgent '*' or 'robots' directive)
181
+ // are blocked from crawling.
182
+ // If at least one bot is allowed, we pass the audit. Any known bots that are not allowed
183
+ // will be listed in a warning.
184
+
185
+ /** @type {Array<string|undefined>} */
186
+ const blockedUserAgents = [];
187
+ const genericBlockingDirectives = [];
188
+
189
+ for (const userAgent of BOT_USER_AGENTS) {
190
+ const blockingDirectives = IsCrawlable.determineIfCrawlableForUserAgent(
191
+ userAgent, mainResource, artifacts.MetaElements, parsedRobotsTxt, robotsTxtUrl);
192
+ if (blockingDirectives.length > 0) {
193
+ blockedUserAgents.push(userAgent);
194
+ }
195
+ if (userAgent === undefined) {
196
+ genericBlockingDirectives.push(...blockingDirectives);
111
197
  }
112
198
  }
113
199
 
114
- mainResource.responseHeaders && mainResource.responseHeaders
115
- .filter(h => h.name.toLowerCase() === ROBOTS_HEADER && !hasUserAgent(h.value) &&
116
- hasBlockingDirective(h.value))
117
- .forEach(h => blockingDirectives.push({source: `${h.name}: ${h.value}`}));
118
-
119
- if (artifacts.RobotsTxt.content) {
120
- const robotsFileUrl = new URL('/robots.txt', mainResource.url);
121
- const robotsTxt = robotsParser(robotsFileUrl.href, artifacts.RobotsTxt.content);
122
-
123
- if (!robotsTxt.isAllowed(mainResource.url)) {
124
- const line = robotsTxt.getMatchingLineNumber(mainResource.url) || 1;
125
- blockingDirectives.push({
126
- source: {
127
- type: /** @type {const} */ ('source-location'),
128
- url: robotsFileUrl.href,
129
- urlProvider: /** @type {const} */ ('network'),
130
- line: line - 1,
131
- column: 0,
132
- },
133
- });
134
- }
200
+ const score = blockedUserAgents.length === BOT_USER_AGENTS.size ? 0 : 1;
201
+ const warnings = [];
202
+ if (score && blockedUserAgents.length > 0) {
203
+ const list = blockedUserAgents.filter(Boolean).join(', ');
204
+ // eslint-disable-next-line max-len
205
+ warnings.push(`The following bot user agents are blocked from crawling: ${list}. The audit is otherwise passing, because at least one bot was explicitly allowed.`);
135
206
  }
136
207
 
137
208
  /** @type {LH.Audit.Details.Table['headings']} */
138
209
  const headings = [
139
210
  {key: 'source', valueType: 'code', label: 'Blocking Directive Source'},
140
211
  ];
141
- const details = Audit.makeTableDetails(headings, blockingDirectives);
212
+ const details = Audit.makeTableDetails(headings, score === 0 ? genericBlockingDirectives : []);
142
213
 
143
214
  return {
144
- score: Number(blockingDirectives.length === 0),
215
+ score,
145
216
  details,
217
+ warnings,
146
218
  };
147
219
  }
148
220
  }
@@ -364,18 +364,18 @@ function resolveSettings(settingsJson = {}, overrides = undefined) {
364
364
  }
365
365
 
366
366
  /**
367
- * @param {LH.Config.Json} configJSON
367
+ * @param {LH.Config} config
368
368
  * @param {string | undefined} configDir
369
369
  * @param {{plugins?: string[]} | undefined} flags
370
- * @return {Promise<LH.Config.Json>}
370
+ * @return {Promise<LH.Config>}
371
371
  */
372
- async function mergePlugins(configJSON, configDir, flags) {
373
- const configPlugins = configJSON.plugins || [];
372
+ async function mergePlugins(config, configDir, flags) {
373
+ const configPlugins = config.plugins || [];
374
374
  const flagPlugins = flags?.plugins || [];
375
375
  const pluginNames = new Set([...configPlugins, ...flagPlugins]);
376
376
 
377
377
  for (const pluginName of pluginNames) {
378
- validation.assertValidPluginName(configJSON, pluginName);
378
+ validation.assertValidPluginName(config, pluginName);
379
379
 
380
380
  // In bundled contexts, `resolveModulePath` will fail, so use the raw pluginName directly.
381
381
  const pluginPath = isBundledEnvironment() ?
@@ -384,10 +384,10 @@ async function mergePlugins(configJSON, configDir, flags) {
384
384
  const rawPluginJson = await requireWrapper(pluginPath);
385
385
  const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName);
386
386
 
387
- configJSON = mergeConfigFragment(configJSON, pluginJson);
387
+ config = mergeConfigFragment(config, pluginJson);
388
388
  }
389
389
 
390
- return configJSON;
390
+ return config;
391
391
  }
392
392
 
393
393
 
@@ -428,7 +428,7 @@ async function resolveGathererToDefn(gathererJson, coreGathererList, configDir)
428
428
  * Take an array of audits and audit paths and require any paths (possibly
429
429
  * relative to the optional `configDir`) using `resolveModule`,
430
430
  * leaving only an array of AuditDefns.
431
- * @param {LH.Config.Json['audits']} audits
431
+ * @param {LH.Config['audits']} audits
432
432
  * @param {string=} configDir
433
433
  * @return {Promise<Array<LH.Config.AuditDefn>|null>}
434
434
  */
@@ -585,10 +585,10 @@ function deepClone(json) {
585
585
  }
586
586
 
587
587
  /**
588
- * Deep clone a ConfigJson, copying over any "live" gatherer or audit that
588
+ * Deep clone a config, copying over any "live" gatherer or audit that
589
589
  * wouldn't make the JSON round trip.
590
- * @param {LH.Config.Json} json
591
- * @return {LH.Config.Json}
590
+ * @param {LH.Config} json
591
+ * @return {LH.Config}
592
592
  */
593
593
  function deepCloneConfigJson(json) {
594
594
  const cloned = deepClone(json);
@@ -215,11 +215,11 @@ class ConfigPlugin {
215
215
  }
216
216
 
217
217
  /**
218
- * Extracts and validates a ConfigJson from the provided plugin input, throwing
218
+ * Extracts and validates a config from the provided plugin input, throwing
219
219
  * if it deviates from the expected object shape.
220
220
  * @param {unknown} pluginJson
221
221
  * @param {string} pluginName
222
- * @return {LH.Config.Json}
222
+ * @return {LH.Config}
223
223
  */
224
224
  static parsePlugin(pluginJson, pluginName) {
225
225
  // Clone to prevent modifications of original and to deactivate any live properties.
@@ -38,19 +38,19 @@ const defaultConfigPath = path.join(
38
38
  );
39
39
 
40
40
  /**
41
- * @param {LH.Config.Json|undefined} configJSON
41
+ * @param {LH.Config|undefined} config
42
42
  * @param {{configPath?: string}} context
43
- * @return {{configWorkingCopy: LH.Config.Json, configDir?: string, configPath?: string}}
43
+ * @return {{configWorkingCopy: LH.Config, configDir?: string, configPath?: string}}
44
44
  */
45
- function resolveWorkingCopy(configJSON, context) {
45
+ function resolveWorkingCopy(config, context) {
46
46
  let {configPath} = context;
47
47
 
48
48
  if (configPath && !path.isAbsolute(configPath)) {
49
49
  throw new Error('configPath must be an absolute path');
50
50
  }
51
51
 
52
- if (!configJSON) {
53
- configJSON = defaultConfig;
52
+ if (!config) {
53
+ config = defaultConfig;
54
54
  configPath = defaultConfigPath;
55
55
  }
56
56
 
@@ -58,24 +58,24 @@ function resolveWorkingCopy(configJSON, context) {
58
58
  const configDir = configPath ? path.dirname(configPath) : undefined;
59
59
 
60
60
  return {
61
- configWorkingCopy: deepCloneConfigJson(configJSON),
61
+ configWorkingCopy: deepCloneConfigJson(config),
62
62
  configPath,
63
63
  configDir,
64
64
  };
65
65
  }
66
66
 
67
67
  /**
68
- * @param {LH.Config.Json} configJSON
69
- * @return {LH.Config.Json}
68
+ * @param {LH.Config} config
69
+ * @return {LH.Config}
70
70
  */
71
- function resolveExtensions(configJSON) {
72
- if (!configJSON.extends) return configJSON;
71
+ function resolveExtensions(config) {
72
+ if (!config.extends) return config;
73
73
 
74
- if (configJSON.extends !== 'lighthouse:default') {
74
+ if (config.extends !== 'lighthouse:default') {
75
75
  throw new Error('`lighthouse:default` is the only valid extension method.');
76
76
  }
77
77
 
78
- const {artifacts, ...extensionJSON} = configJSON;
78
+ const {artifacts, ...extensionJSON} = config;
79
79
  const defaultClone = deepCloneConfigJson(defaultConfig);
80
80
  const mergedConfig = mergeConfigFragment(defaultClone, extensionJSON);
81
81
 
@@ -236,15 +236,15 @@ function resolveFakeNavigations(artifactDefns, settings) {
236
236
 
237
237
  /**
238
238
  * @param {LH.Gatherer.GatherMode} gatherMode
239
- * @param {LH.Config.Json=} configJSON
239
+ * @param {LH.Config=} config
240
240
  * @param {LH.Flags=} flags
241
241
  * @return {Promise<{resolvedConfig: LH.Config.ResolvedConfig, warnings: string[]}>}
242
242
  */
243
- async function initializeConfig(gatherMode, configJSON, flags = {}) {
243
+ async function initializeConfig(gatherMode, config, flags = {}) {
244
244
  const status = {msg: 'Initialize config', id: 'lh:config'};
245
245
  log.time(status, 'verbose');
246
246
 
247
- let {configWorkingCopy, configDir} = resolveWorkingCopy(configJSON, flags);
247
+ let {configWorkingCopy, configDir} = resolveWorkingCopy(config, flags);
248
248
 
249
249
  configWorkingCopy = resolveExtensions(configWorkingCopy);
250
250
  configWorkingCopy = await mergePlugins(configWorkingCopy, configDir, flags);
@@ -170,7 +170,7 @@ for (const key of Object.keys(artifacts)) {
170
170
  artifacts[/** @type {keyof typeof artifacts} */ (key)] = key;
171
171
  }
172
172
 
173
- /** @type {LH.Config.Json} */
173
+ /** @type {LH.Config} */
174
174
  const defaultConfig = {
175
175
  settings: constants.defaultSettings,
176
176
  artifacts: [
@@ -217,8 +217,11 @@ const defaultConfig = {
217
217
  {id: artifacts.devtoolsLogs, gatherer: 'devtools-log-compat'},
218
218
  {id: artifacts.traces, gatherer: 'trace-compat'},
219
219
 
220
- // FullPageScreenshot comes at the very end so all other node analysis is captured.
220
+ // FullPageScreenshot comes at the end so all other node analysis is captured.
221
221
  {id: artifacts.FullPageScreenshot, gatherer: 'full-page-screenshot'},
222
+
223
+ // BFCacheErrors comes at the very end because it can perform a page navigation.
224
+ {id: artifacts.BFCacheFailures, gatherer: 'bf-cache-failures'},
222
225
  ],
223
226
  audits: [
224
227
  'is-on-https',
@@ -374,6 +377,7 @@ const defaultConfig = {
374
377
  'seo/canonical',
375
378
  'seo/manual/structured-data',
376
379
  'work-during-interaction',
380
+ 'bf-cache',
377
381
  ],
378
382
  groups: {
379
383
  'metrics': {
@@ -513,9 +517,9 @@ const defaultConfig = {
513
517
  {id: 'non-composited-animations', weight: 0},
514
518
  {id: 'unsized-images', weight: 0},
515
519
  {id: 'viewport', weight: 0},
516
- {id: 'no-unload-listeners', weight: 0},
517
520
  {id: 'uses-responsive-images-snapshot', weight: 0},
518
521
  {id: 'work-during-interaction', weight: 0},
522
+ {id: 'bf-cache', weight: 0},
519
523
 
520
524
  // Budget audits.
521
525
  {id: 'performance-budget', weight: 0, group: 'budgets'},
@@ -618,6 +622,7 @@ const defaultConfig = {
618
622
  {id: 'doctype', weight: 1, group: 'best-practices-browser-compat'},
619
623
  {id: 'charset', weight: 1, group: 'best-practices-browser-compat'},
620
624
  // General Group
625
+ {id: 'no-unload-listeners', weight: 1, group: 'best-practices-general'},
621
626
  {id: 'js-libraries', weight: 0, group: 'best-practices-general'},
622
627
  {id: 'deprecations', weight: 1, group: 'best-practices-general'},
623
628
  {id: 'errors-in-console', weight: 1, group: 'best-practices-general'},
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as constants from './constants.js';
8
8
 
9
- /** @type {LH.Config.Json} */
9
+ /** @type {LH.Config} */
10
10
  const config = {
11
11
  extends: 'lighthouse:default',
12
12
  settings: {
@@ -9,7 +9,7 @@
9
9
  * being enabled by default.
10
10
  */
11
11
 
12
- /** @type {LH.Config.Json} */
12
+ /** @type {LH.Config} */
13
13
  const config = {
14
14
  extends: 'lighthouse:default',
15
15
  audits: [
@@ -4,7 +4,7 @@
4
4
  * 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.
5
5
  */
6
6
 
7
- /** @type {LH.Config.Json} */
7
+ /** @type {LH.Config} */
8
8
  const fullConfig = {
9
9
  extends: 'lighthouse:default',
10
10
  settings: {},
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as constants from './constants.js';
8
8
 
9
- /** @type {LH.Config.Json} */
9
+ /** @type {LH.Config} */
10
10
  const config = {
11
11
  extends: 'lighthouse:default',
12
12
  settings: {
@@ -4,7 +4,7 @@
4
4
  * 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.
5
5
  */
6
6
 
7
- /** @type {LH.Config.Json} */
7
+ /** @type {LH.Config} */
8
8
  const config = {
9
9
  extends: 'lighthouse:default',
10
10
  settings: {
@@ -4,7 +4,7 @@
4
4
  * 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.
5
5
  */
6
6
 
7
- /** @type {LH.Config.Json} */
7
+ /** @type {LH.Config} */
8
8
  const perfConfig = {
9
9
  extends: 'lighthouse:default',
10
10
  settings: {
@@ -39,16 +39,16 @@ function isValidArtifactDependency(dependent, dependency) {
39
39
 
40
40
  /**
41
41
  * Throws if pluginName is invalid or (somehow) collides with a category in the
42
- * configJSON being added to.
43
- * @param {LH.Config.Json} configJSON
42
+ * config being added to.
43
+ * @param {LH.Config} config
44
44
  * @param {string} pluginName
45
45
  */
46
- function assertValidPluginName(configJSON, pluginName) {
46
+ function assertValidPluginName(config, pluginName) {
47
47
  if (!pluginName.startsWith('lighthouse-plugin-')) {
48
48
  throw new Error(`plugin name '${pluginName}' does not start with 'lighthouse-plugin-'`);
49
49
  }
50
50
 
51
- if (configJSON.categories?.[pluginName]) {
51
+ if (config.categories?.[pluginName]) {
52
52
  throw new Error(`plugin name '${pluginName}' not allowed because it is the id of a category already found in config`); // eslint-disable-line max-len
53
53
  }
54
54
  }
@@ -61,8 +61,8 @@ class ExecutionContext {
61
61
  await this._session.sendCommand('Page.enable');
62
62
  await this._session.sendCommand('Runtime.enable');
63
63
 
64
- const resourceTreeResponse = await this._session.sendCommand('Page.getResourceTree');
65
- const mainFrameId = resourceTreeResponse.frameTree.frame.id;
64
+ const frameTreeResponse = await this._session.sendCommand('Page.getFrameTree');
65
+ const mainFrameId = frameTreeResponse.frameTree.frame.id;
66
66
 
67
67
  const isolatedWorldResponse = await this._session.sendCommand('Page.createIsolatedWorld', {
68
68
  frameId: mainFrameId,
@@ -95,8 +95,8 @@ class NetworkMonitor extends NetworkMonitorEventEmitter {
95
95
  const frameNavigations = this._frameNavigations;
96
96
  if (!frameNavigations.length) return {};
97
97
 
98
- const resourceTreeResponse = await this._session.sendCommand('Page.getResourceTree');
99
- const mainFrameId = resourceTreeResponse.frameTree.frame.id;
98
+ const frameTreeResponse = await this._session.sendCommand('Page.getFrameTree');
99
+ const mainFrameId = frameTreeResponse.frameTree.frame.id;
100
100
  const mainFrameNavigations = frameNavigations.filter(frame => frame.id === mainFrameId);
101
101
  if (!mainFrameNavigations.length) log.warn('NetworkMonitor', 'No detected navigations');
102
102
 
@@ -13,7 +13,13 @@ import {ExecutionContext} from './execution-context.js';
13
13
 
14
14
  /** @typedef {InstanceType<import('./network-monitor.js')['NetworkMonitor']>} NetworkMonitor */
15
15
  /** @typedef {import('./network-monitor.js').NetworkMonitorEvent} NetworkMonitorEvent */
16
- /** @typedef {{promise: Promise<void>, cancel: function(): void}} CancellableWait */
16
+
17
+ /**
18
+ * @template [T=void]
19
+ * @typedef CancellableWait
20
+ * @prop {Promise<T>} promise
21
+ * @prop {() => void} cancel
22
+ */
17
23
 
18
24
  /**
19
25
  * @typedef WaitOptions
@@ -40,7 +46,7 @@ function waitForNothing() {
40
46
  * Returns a promise that resolve when a frame has been navigated.
41
47
  * Used for detecting that our about:blank reset has been completed.
42
48
  * @param {LH.Gatherer.FRProtocolSession} session
43
- * @return {CancellableWait}
49
+ * @return {CancellableWait<LH.Crdp.Page.FrameNavigatedEvent>}
44
50
  */
45
51
  function waitForFrameNavigated(session) {
46
52
  /** @type {(() => void)} */
@@ -48,6 +54,7 @@ function waitForFrameNavigated(session) {
48
54
  throw new Error('waitForFrameNavigated.cancel() called before it was defined');
49
55
  };
50
56
 
57
+ /** @type {Promise<LH.Crdp.Page.FrameNavigatedEvent>} */
51
58
  const promise = new Promise((resolve, reject) => {
52
59
  session.once('Page.frameNavigated', resolve);
53
60
  cancel = () => {
@@ -8,6 +8,8 @@ import FRGatherer from '../base-gatherer.js';
8
8
  import {waitForFrameNavigated, waitForLoadEvent} from '../driver/wait-for-condition.js';
9
9
  import DevtoolsLog from './devtools-log.js';
10
10
 
11
+ const FAILURE_EVENT_TIMEOUT = 100;
12
+
11
13
  class BFCacheFailures extends FRGatherer {
12
14
  /** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
13
15
  meta = {
@@ -101,16 +103,32 @@ class BFCacheFailures extends FRGatherer {
101
103
  const history = await session.sendCommand('Page.getNavigationHistory');
102
104
  const entry = history.entries[history.currentIndex];
103
105
 
106
+ // In theory, we should be able to use about:blank here
107
+ // but that sometimes produces BrowsingInstanceNotSwapped failures.
108
+ // DevTools uses chrome://terms as it's temporary page so we should stick with that.
109
+ // https://github.com/GoogleChrome/lighthouse/issues/14665
104
110
  await Promise.all([
105
- session.sendCommand('Page.navigate', {url: 'about:blank'}),
111
+ session.sendCommand('Page.navigate', {url: 'chrome://terms'}),
106
112
  waitForLoadEvent(session, 0).promise,
107
113
  ]);
108
114
 
109
- await Promise.all([
115
+ const [, frameNavigatedEvent] = await Promise.all([
110
116
  session.sendCommand('Page.navigateToHistoryEntry', {entryId: entry.id}),
111
117
  waitForFrameNavigated(session).promise,
112
118
  ]);
113
119
 
120
+ // The bfcache failure event is not necessarily emitted by this point.
121
+ // If we are expecting a bfcache failure event but haven't seen one, we should wait for it.
122
+ if (frameNavigatedEvent.type !== 'BackForwardCacheRestore' && !bfCacheEvent) {
123
+ await new Promise(resolve => setTimeout(resolve, FAILURE_EVENT_TIMEOUT));
124
+
125
+ // If we still can't get the failure reasons after the timeout we should fail loudly,
126
+ // otherwise this gatherer will return no failures when there should be failures.
127
+ if (!bfCacheEvent) {
128
+ throw new Error('bfcache failed but the failure reasons were not emitted in time');
129
+ }
130
+ }
131
+
114
132
  session.off('Page.backForwardCacheNotUsed', onBfCacheNotUsed);
115
133
 
116
134
  return bfCacheEvent;
@@ -136,7 +154,7 @@ class BFCacheFailures extends FRGatherer {
136
154
  */
137
155
  async getArtifact(context) {
138
156
  const events = this.passivelyCollectBFCacheEvents(context);
139
- if (context.gatherMode === 'navigation') {
157
+ if (context.gatherMode === 'navigation' && !context.settings.usePassiveGathering) {
140
158
  const activelyCollectedEvent = await this.activelyCollectBFCacheEvent(context);
141
159
  if (activelyCollectedEvent) events.push(activelyCollectedEvent);
142
160
  }
@@ -311,7 +311,7 @@ async function _cleanup({requestedUrl, driver, resolvedConfig}) {
311
311
  /**
312
312
  * @param {LH.Puppeteer.Page|undefined} page
313
313
  * @param {LH.NavigationRequestor|undefined} requestor
314
- * @param {{config?: LH.Config.Json, flags?: LH.Flags}} [options]
314
+ * @param {{config?: LH.Config, flags?: LH.Flags}} [options]
315
315
  * @return {Promise<LH.Gatherer.FRGatherResult>}
316
316
  */
317
317
  async function navigationGather(page, requestor, options = {}) {
@@ -14,7 +14,7 @@ import {getBaseArtifacts, finalizeArtifacts} from './base-artifacts.js';
14
14
 
15
15
  /**
16
16
  * @param {LH.Puppeteer.Page} page
17
- * @param {{config?: LH.Config.Json, flags?: LH.Flags}} [options]
17
+ * @param {{config?: LH.Config, flags?: LH.Flags}} [options]
18
18
  * @return {Promise<LH.Gatherer.FRGatherResult>}
19
19
  */
20
20
  async function snapshotGather(page, options = {}) {
@@ -15,7 +15,7 @@ import {getBaseArtifacts, finalizeArtifacts} from './base-artifacts.js';
15
15
 
16
16
  /**
17
17
  * @param {LH.Puppeteer.Page} page
18
- * @param {{config?: LH.Config.Json, flags?: LH.Flags}} [options]
18
+ * @param {{config?: LH.Config, flags?: LH.Flags}} [options]
19
19
  * @return {Promise<{endTimespanGather(): Promise<LH.Gatherer.FRGatherResult>}>}
20
20
  */
21
21
  async function startTimespanGather(page, options = {}) {