lighthouse 13.5.0 → 13.6.0-dev.20261028

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 (272) hide show
  1. package/.agents/skills/update-ard-port/SKILL.md +171 -0
  2. package/.agents/skills/update-trace-engine/SKILL.md +154 -0
  3. package/cli/bin.d.ts +2 -2
  4. package/cli/bin.js +5 -4
  5. package/cli/cli-flags.d.ts +16 -1
  6. package/cli/cli-flags.js +53 -2
  7. package/cli/printer.js +5 -8
  8. package/cli/run.d.ts +5 -5
  9. package/cli/run.js +97 -47
  10. package/cli/sentry-prompt.js +3 -4
  11. package/cli/test/smokehouse/__snapshots__/report-assert-test.js.snap +1 -1
  12. package/cli/test/smokehouse/config/exclusions.js +3 -1
  13. package/cli/test/smokehouse/frontends/smokehouse-bin.js +5 -3
  14. package/cli/test/smokehouse/report-assert-test.js +65 -0
  15. package/cli/test/smokehouse/report-assert.js +24 -39
  16. package/cli/test/smokehouse/smokehouse-test.d.ts +2 -0
  17. package/cli/test/smokehouse/smokehouse-test.js +120 -0
  18. package/cli/test/smokehouse/smokehouse.js +37 -15
  19. package/cli/test/smokehouse/version-check-test.js +5 -0
  20. package/cli/test/smokehouse/version-check.d.ts +1 -1
  21. package/cli/test/smokehouse/version-check.js +1 -1
  22. package/commitlint.config.js +1 -1
  23. package/core/audits/accessibility/accesskeys.js +1 -1
  24. package/core/audits/accessibility/aria-allowed-attr.js +1 -1
  25. package/core/audits/accessibility/aria-allowed-role.js +1 -1
  26. package/core/audits/accessibility/aria-command-name.js +1 -1
  27. package/core/audits/accessibility/aria-conditional-attr.js +1 -1
  28. package/core/audits/accessibility/aria-deprecated-role.js +1 -1
  29. package/core/audits/accessibility/aria-dialog-name.js +1 -1
  30. package/core/audits/accessibility/aria-hidden-body.js +1 -1
  31. package/core/audits/accessibility/aria-hidden-focus.js +1 -1
  32. package/core/audits/accessibility/aria-input-field-name.js +1 -1
  33. package/core/audits/accessibility/aria-meter-name.js +1 -1
  34. package/core/audits/accessibility/aria-progressbar-name.js +1 -1
  35. package/core/audits/accessibility/aria-prohibited-attr.js +1 -1
  36. package/core/audits/accessibility/aria-required-attr.js +1 -1
  37. package/core/audits/accessibility/aria-required-children.js +1 -1
  38. package/core/audits/accessibility/aria-required-parent.js +1 -1
  39. package/core/audits/accessibility/aria-roles.js +1 -1
  40. package/core/audits/accessibility/aria-text.js +1 -1
  41. package/core/audits/accessibility/aria-toggle-field-name.js +1 -1
  42. package/core/audits/accessibility/aria-tooltip-name.js +1 -1
  43. package/core/audits/accessibility/aria-treeitem-name.js +1 -1
  44. package/core/audits/accessibility/aria-valid-attr-value.js +1 -1
  45. package/core/audits/accessibility/aria-valid-attr.js +1 -1
  46. package/core/audits/accessibility/autocomplete-valid.js +1 -1
  47. package/core/audits/accessibility/button-name.js +1 -1
  48. package/core/audits/accessibility/bypass.js +1 -1
  49. package/core/audits/accessibility/color-contrast.js +1 -1
  50. package/core/audits/accessibility/definition-list.js +1 -1
  51. package/core/audits/accessibility/dlitem.js +1 -1
  52. package/core/audits/accessibility/document-title.js +1 -1
  53. package/core/audits/accessibility/duplicate-id-aria.js +1 -1
  54. package/core/audits/accessibility/empty-heading.js +1 -1
  55. package/core/audits/accessibility/form-field-multiple-labels.js +1 -1
  56. package/core/audits/accessibility/frame-title.js +1 -1
  57. package/core/audits/accessibility/heading-order.js +1 -1
  58. package/core/audits/accessibility/html-has-lang.js +1 -1
  59. package/core/audits/accessibility/html-lang-valid.js +1 -1
  60. package/core/audits/accessibility/html-xml-lang-mismatch.js +1 -1
  61. package/core/audits/accessibility/identical-links-same-purpose.js +1 -1
  62. package/core/audits/accessibility/image-alt.js +1 -1
  63. package/core/audits/accessibility/image-redundant-alt.js +1 -1
  64. package/core/audits/accessibility/input-button-name.js +1 -1
  65. package/core/audits/accessibility/input-image-alt.js +1 -1
  66. package/core/audits/accessibility/label-content-name-mismatch.js +1 -1
  67. package/core/audits/accessibility/label.js +1 -1
  68. package/core/audits/accessibility/landmark-one-main.js +1 -1
  69. package/core/audits/accessibility/link-in-text-block.js +1 -1
  70. package/core/audits/accessibility/link-name.js +1 -1
  71. package/core/audits/accessibility/list.js +1 -1
  72. package/core/audits/accessibility/listitem.js +1 -1
  73. package/core/audits/accessibility/meta-refresh.js +1 -1
  74. package/core/audits/accessibility/meta-viewport.js +1 -1
  75. package/core/audits/accessibility/object-alt.js +1 -1
  76. package/core/audits/accessibility/presentation-role-conflict.js +1 -1
  77. package/core/audits/accessibility/select-name.js +1 -1
  78. package/core/audits/accessibility/skip-link.js +1 -1
  79. package/core/audits/accessibility/svg-img-alt.js +1 -1
  80. package/core/audits/accessibility/tabindex.js +1 -1
  81. package/core/audits/accessibility/table-duplicate-name.js +1 -1
  82. package/core/audits/accessibility/table-fake-caption.js +1 -1
  83. package/core/audits/accessibility/target-size.js +1 -1
  84. package/core/audits/accessibility/td-has-header.js +1 -1
  85. package/core/audits/accessibility/td-headers-attr.js +1 -1
  86. package/core/audits/accessibility/th-has-data-cells.js +1 -1
  87. package/core/audits/accessibility/valid-lang.js +1 -1
  88. package/core/audits/accessibility/video-caption.js +1 -1
  89. package/core/audits/agentic/ard-schema.d.ts +13 -0
  90. package/core/audits/agentic/ard-schema.js +81 -33
  91. package/core/audits/agentic/llms-txt.js +5 -2
  92. package/core/audits/baseline.d.ts +29 -7
  93. package/core/audits/baseline.js +19 -6
  94. package/core/audits/byte-efficiency/byte-efficiency-audit.js +2 -2
  95. package/core/audits/insights/insight-audit.d.ts +9 -0
  96. package/core/audits/insights/insight-audit.js +35 -28
  97. package/core/audits/long-tasks.js +2 -1
  98. package/core/audits/predictive-perf.js +2 -2
  99. package/core/audits/screenshot-thumbnails.d.ts +0 -13
  100. package/core/audits/screenshot-thumbnails.js +0 -45
  101. package/core/audits/script-treemap-data.js +26 -6
  102. package/core/audits/seo/robots-txt.js +3 -8
  103. package/core/audits/unsized-images.d.ts +0 -7
  104. package/core/audits/unsized-images.js +7 -26
  105. package/core/computed/lcp-image-record.d.ts +2 -23
  106. package/core/computed/lcp-image-record.js +3 -67
  107. package/core/computed/lcp-network-record.d.ts +24 -0
  108. package/core/computed/lcp-network-record.js +74 -0
  109. package/core/computed/metrics/lantern-metric.js +1 -1
  110. package/core/computed/metrics/lcp-breakdown.js +6 -6
  111. package/core/computed/metrics/time-to-first-byte.js +5 -3
  112. package/core/computed/navigation-insights.js +3 -2
  113. package/core/computed/page-dependency-graph.js +55 -5
  114. package/core/computed/speedline.js +20 -5
  115. package/core/computed/trace-engine-result.js +25 -5
  116. package/core/computed/unused-css.js +15 -2
  117. package/core/config/config-helpers.d.ts +5 -0
  118. package/core/config/config-helpers.js +17 -28
  119. package/core/config/config-plugin.d.ts +7 -0
  120. package/core/config/config-plugin.js +49 -0
  121. package/core/config/config.js +10 -2
  122. package/core/config/constants.d.ts +2 -28
  123. package/core/config/filters.js +1 -0
  124. package/core/config/lr-desktop-config.js +13 -0
  125. package/core/config/lr-mobile-config.js +13 -0
  126. package/core/gather/base-artifacts.js +7 -1
  127. package/core/gather/driver/storage.js +3 -2
  128. package/core/gather/gatherers/agentic/ard.d.ts +59 -4
  129. package/core/gather/gatherers/agentic/ard.js +218 -56
  130. package/core/gather/gatherers/full-page-screenshot.js +2 -2
  131. package/core/gather/gatherers/image-elements.d.ts +7 -0
  132. package/core/gather/gatherers/image-elements.js +75 -24
  133. package/core/gather/gatherers/inspector-issues.js +2 -0
  134. package/core/gather/gatherers/js-usage.js +2 -0
  135. package/core/gather/gatherers/scripts.d.ts +16 -3
  136. package/core/gather/gatherers/scripts.js +157 -14
  137. package/core/gather/gatherers/stylesheets.js +2 -0
  138. package/core/gather/gatherers/trace-elements.js +2 -1
  139. package/core/gather/gatherers/trace.d.ts +6 -0
  140. package/core/gather/gatherers/trace.js +56 -16
  141. package/core/index.d.ts +2 -0
  142. package/core/index.js +2 -0
  143. package/core/lib/arbitrary-equality-map.d.ts +14 -3
  144. package/core/lib/arbitrary-equality-map.js +113 -6
  145. package/core/lib/baseline/web-features-data.json +32 -11
  146. package/core/lib/baseline/web-features-metadata.json +1 -1
  147. package/core/lib/bf-cache-strings.js +237 -254
  148. package/core/lib/cdt/Common.d.ts +1 -1
  149. package/core/lib/cdt/Common.js +2 -2
  150. package/core/lib/cdt/Platform.d.ts +7 -7
  151. package/core/lib/cdt/Platform.js +11 -8
  152. package/core/lib/cdt/SDK.d.ts +5 -1
  153. package/core/lib/cdt/SDK.js +4 -2
  154. package/core/lib/cdt/generated/ParsedURL.d.ts +0 -1
  155. package/core/lib/cdt/generated/ParsedURL.js +3 -9
  156. package/core/lib/cdt/generated/SourceMap.d.ts +24 -25
  157. package/core/lib/cdt/generated/SourceMap.js +7 -15
  158. package/core/lib/deprecations-strings.d.ts +93 -88
  159. package/core/lib/deprecations-strings.js +20 -14
  160. package/core/lib/i18n/i18n.d.ts +2 -1
  161. package/core/lib/i18n/i18n.js +25 -13
  162. package/core/lib/legacy-javascript/legacy-javascript.d.ts +3 -2
  163. package/core/lib/legacy-javascript/legacy-javascript.js +235 -111
  164. package/core/lib/legacy-javascript/polyfill-graph-data.json +94 -89
  165. package/core/lib/legacy-javascript/polyfill-module-data.json +48 -7
  166. package/core/lib/minification-estimator.js +10 -10
  167. package/core/lib/network-request.d.ts +2 -0
  168. package/core/lib/network-request.js +21 -0
  169. package/core/lib/page-functions.js +2 -5
  170. package/core/lib/sentry.d.ts +1 -0
  171. package/core/lib/sentry.js +3 -0
  172. package/core/lib/trace-engine.d.ts +1 -0
  173. package/core/lib/trace-engine.js +2 -0
  174. package/core/runner.js +9 -3
  175. package/dist/report/bundle.esm.js +10 -10
  176. package/dist/report/flow.js +13 -13
  177. package/dist/report/standalone.js +5 -5
  178. package/flow-report/src/common.tsx +5 -2
  179. package/flow-report/src/i18n/i18n.d.ts +2 -0
  180. package/flow-report/src/summary/summary.tsx +9 -1
  181. package/flow-report/src/util.d.ts +1 -1
  182. package/package.json +62 -98
  183. package/readme.md +13 -18
  184. package/report/generator/file-namer.d.ts +1 -1
  185. package/report/generator/file-namer.js +5 -4
  186. package/report/generator/flow-report-assets.js +1 -3
  187. package/report/generator/report-assets.js +1 -2
  188. package/report/renderer/category-renderer.js +2 -2
  189. package/report/renderer/details-renderer.js +3 -2
  190. package/report/renderer/dom.d.ts +8 -2
  191. package/report/renderer/dom.js +29 -1
  192. package/report/renderer/element-screenshot-renderer.js +3 -0
  193. package/report/renderer/performance-category-renderer.js +3 -1
  194. package/report/renderer/report-renderer.js +4 -0
  195. package/report/renderer/report-utils.d.ts +1 -0
  196. package/report/renderer/report-utils.js +2 -0
  197. package/report/renderer/text-encoding.js +2 -2
  198. package/report/types/augment-dom.d.ts +4 -0
  199. package/shared/esm-utils.d.ts +0 -8
  200. package/shared/esm-utils.js +0 -18
  201. package/shared/localization/format.js +5 -3
  202. package/shared/localization/locales/ar-XB.json +157 -142
  203. package/shared/localization/locales/ar.json +157 -142
  204. package/shared/localization/locales/bg.json +154 -139
  205. package/shared/localization/locales/ca.json +157 -142
  206. package/shared/localization/locales/cs.json +148 -133
  207. package/shared/localization/locales/da.json +153 -138
  208. package/shared/localization/locales/de.json +152 -137
  209. package/shared/localization/locales/el.json +153 -138
  210. package/shared/localization/locales/en-GB.json +160 -145
  211. package/shared/localization/locales/en-US.json +239 -218
  212. package/shared/localization/locales/en-XL.json +239 -218
  213. package/shared/localization/locales/es-419.json +150 -135
  214. package/shared/localization/locales/es.json +149 -134
  215. package/shared/localization/locales/fi.json +151 -136
  216. package/shared/localization/locales/fil.json +154 -139
  217. package/shared/localization/locales/fr.json +150 -135
  218. package/shared/localization/locales/he.json +160 -145
  219. package/shared/localization/locales/hi.json +156 -141
  220. package/shared/localization/locales/hr.json +154 -139
  221. package/shared/localization/locales/hu.json +111 -96
  222. package/shared/localization/locales/id.json +153 -138
  223. package/shared/localization/locales/it.json +153 -138
  224. package/shared/localization/locales/ja.json +150 -135
  225. package/shared/localization/locales/ko.json +42 -27
  226. package/shared/localization/locales/lt.json +152 -137
  227. package/shared/localization/locales/lv.json +93 -78
  228. package/shared/localization/locales/nl.json +149 -134
  229. package/shared/localization/locales/no.json +152 -137
  230. package/shared/localization/locales/pl.json +153 -138
  231. package/shared/localization/locales/pt-PT.json +152 -137
  232. package/shared/localization/locales/pt.json +157 -142
  233. package/shared/localization/locales/ro.json +149 -134
  234. package/shared/localization/locales/ru.json +76 -61
  235. package/shared/localization/locales/sk.json +153 -138
  236. package/shared/localization/locales/sl.json +149 -134
  237. package/shared/localization/locales/sr-Latn.json +151 -136
  238. package/shared/localization/locales/sr.json +151 -136
  239. package/shared/localization/locales/sv.json +152 -137
  240. package/shared/localization/locales/ta.json +156 -141
  241. package/shared/localization/locales/te.json +157 -142
  242. package/shared/localization/locales/th.json +111 -96
  243. package/shared/localization/locales/tr.json +153 -138
  244. package/shared/localization/locales/uk.json +149 -134
  245. package/shared/localization/locales/vi.json +162 -147
  246. package/shared/localization/locales/zh-HK.json +150 -135
  247. package/shared/localization/locales/zh-TW.json +154 -139
  248. package/shared/localization/locales/zh.json +152 -137
  249. package/shared/localization/swap-locale.js +10 -5
  250. package/shared/root.js +1 -3
  251. package/third-party/ard/README.md +19 -10
  252. package/third-party/ard/ard-test.js +189 -266
  253. package/third-party/ard/ard.d.ts +18 -1
  254. package/third-party/ard/ard.js +282 -45
  255. package/third-party/ard/schema-validator.d.ts +14 -5
  256. package/third-party/ard/schema-validator.js +921 -1177
  257. package/third-party/ard/spec/schemas/ard-entry.schema.json +176 -0
  258. package/tsconfig.json +0 -2
  259. package/types/artifacts.d.ts +43 -7
  260. package/types/config.d.ts +6 -1
  261. package/types/externs.d.ts +4 -0
  262. package/types/internal/test.d.ts +1 -0
  263. package/build-tracker.config.js +0 -16
  264. package/core/lib/cdt/package.json +0 -4
  265. package/eslint-local-rules.d.cts +0 -9
  266. package/eslint.config.mjs +0 -242
  267. package/third-party/ard/spec/schemas/ai-catalog.schema.json +0 -292
  268. package/third-party/esbuild-plugins-polyfills/LICENSE +0 -3
  269. package/third-party/esbuild-plugins-polyfills/README.md +0 -7
  270. package/third-party/esbuild-plugins-polyfills/esbuild-polyfills.d.ts +0 -9
  271. package/third-party/esbuild-plugins-polyfills/esbuild-polyfills.js +0 -129
  272. package/types/internal/lookup-closest-locale.d.ts +0 -13
package/cli/run.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  /* eslint-disable no-console */
8
8
 
9
+ import fs from 'fs';
9
10
  import path from 'path';
10
11
  import os from 'os';
11
12
 
@@ -131,7 +132,7 @@ function printErrorAndExit(err) {
131
132
  /**
132
133
  * @param {LH.RunnerResult} runnerResult
133
134
  * @param {LH.CliFlags} flags
134
- * @return {Promise<void>}
135
+ * @return {Promise<string|undefined>}
135
136
  */
136
137
  async function saveResults(runnerResult, flags) {
137
138
  const cwd = process.cwd();
@@ -145,43 +146,55 @@ async function saveResults(runnerResult, flags) {
145
146
  if (!shouldSaveResults) return;
146
147
  const {lhr, artifacts, report} = runnerResult;
147
148
 
149
+ const isDirectoryOutput = Boolean(
150
+ flags.outputPath &&
151
+ fs.existsSync(flags.outputPath) &&
152
+ fs.statSync(flags.outputPath).isDirectory()
153
+ );
154
+
148
155
  // Use the output path as the prefix for all generated files.
149
156
  // If no output path is set, generate a file prefix using the URL and date.
150
- const configuredPath = !flags.outputPath || flags.outputPath === 'stdout' ?
151
- getLhrFilenamePrefix(lhr) :
152
- flags.outputPath.replace(/\.\w{2,4}$/, '');
157
+ let configuredPath;
158
+ if (!flags.outputPath || flags.outputPath === 'stdout') {
159
+ configuredPath = getLhrFilenamePrefix(lhr);
160
+ } else if (isDirectoryOutput) {
161
+ configuredPath = path.join(flags.outputPath, getLhrFilenamePrefix(lhr));
162
+ } else {
163
+ configuredPath = flags.outputPath.replace(/\.\w{2,4}$/, '');
164
+ }
153
165
  const resolvedPath = path.resolve(cwd, configuredPath);
154
166
 
155
167
  if (flags.saveAssets) {
156
168
  await assetSaver.saveAssets(artifacts, lhr.audits, resolvedPath);
157
169
  }
158
170
 
171
+ /** @type {string|undefined} */
172
+ let htmlOutputPath;
159
173
  for (const outputType of flags.output) {
160
174
  const extension = outputType;
161
175
  const output = report[flags.output.indexOf(outputType)];
162
176
  let outputPath = `${resolvedPath}.report.${extension}`;
163
- // If there was only a single output and the user specified an outputPath, force usage of it.
164
- if (flags.outputPath && flags.output.length === 1) outputPath = flags.outputPath;
177
+ // If there was only a single output and the user specified a file outputPath, force usage of it.
178
+ if (flags.outputPath && !isDirectoryOutput && flags.output.length === 1) {
179
+ outputPath = flags.outputPath;
180
+ }
165
181
  await Printer.write(output, outputType, outputPath);
166
182
 
167
183
  if (outputType === Printer.OutputMode[Printer.OutputMode.html]) {
168
- if (flags.view) {
169
- open(outputPath, {wait: false});
170
- } else {
171
- // eslint-disable-next-line max-len
172
- log.log('CLI', 'Protip: Run lighthouse with `--view` to immediately open the HTML report in your browser');
173
- }
184
+ htmlOutputPath = outputPath;
174
185
  }
175
186
  }
187
+
188
+ return htmlOutputPath;
176
189
  }
177
190
 
178
191
  /**
179
- * @param {string} url
192
+ * @param {string|string[]|undefined} urlOrUrls
180
193
  * @param {LH.CliFlags} flags
181
194
  * @param {LH.Config|undefined} config
182
- * @return {Promise<LH.RunnerResult|undefined>}
195
+ * @return {Promise<LH.RunnerResult|LH.RunnerResult[]|undefined>}
183
196
  */
184
- async function runLighthouse(url, flags, config) {
197
+ async function runLighthouse(urlOrUrls, flags, config) {
185
198
  /** @param {any} reason */
186
199
  function handleTheUnhandled(reason) {
187
200
  process.stderr.write(`Unhandled Rejection. Reason: ${reason}\n`);
@@ -193,48 +206,85 @@ async function runLighthouse(url, flags, config) {
193
206
  /** @type {ChromeLauncher.LaunchedChrome|undefined} */
194
207
  let launchedChrome;
195
208
 
196
- try {
197
- if (url && flags.auditMode && !flags.gatherMode) {
198
- log.warn('CLI', 'URL parameter is ignored if -A flag is used without -G flag');
199
- }
209
+ const urls = [urlOrUrls].flat();
210
+ if (urls.length === 0) urls.push(undefined);
211
+ const isBatch = urls.length > 1;
200
212
 
201
- const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
202
- const shouldUseLocalChrome = UrlUtils.isLikeLocalhost(flags.hostname);
203
- if (shouldGather && shouldUseLocalChrome) {
204
- launchedChrome = await getDebuggableChrome(flags);
205
- flags.port = launchedChrome.port;
206
- }
213
+ const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
214
+ const shouldUseLocalChrome = UrlUtils.isLikeLocalhost(flags.hostname);
215
+ const initialPort = flags.port;
216
+ flags.channel = 'cli';
207
217
 
208
- flags.channel = 'cli';
218
+ /** @type {Array<LH.RunnerResult>} */
219
+ const results = [];
220
+ /** @type {Array<string>} */
221
+ const htmlReportPaths = [];
222
+ /** @type {Array<ExitError>} */
223
+ const errors = [];
209
224
 
210
- const runnerResult = await lighthouse(url, flags, config);
225
+ try {
226
+ for (const url of urls) {
227
+ if (url && flags.auditMode && !flags.gatherMode) {
228
+ log.warn('CLI', 'URL parameter is ignored if -A flag is used without -G flag');
229
+ }
211
230
 
212
- // If in gatherMode only, there will be no runnerResult.
213
- if (runnerResult) {
214
- await saveResults(runnerResult, flags);
231
+ try {
232
+ if (shouldGather && shouldUseLocalChrome && !launchedChrome) {
233
+ launchedChrome = await getDebuggableChrome({...flags, port: initialPort});
234
+ flags.port = launchedChrome.port;
235
+ }
236
+
237
+ const runnerResult = await lighthouse(url, flags, config);
238
+
239
+ // If in gatherMode only, there will be no runnerResult.
240
+ if (runnerResult) {
241
+ const htmlPath = await saveResults(runnerResult, flags);
242
+ if (htmlPath) htmlReportPaths.push(htmlPath);
243
+ results.push(runnerResult);
244
+ }
245
+
246
+ // Runtime errors indicate something was *very* wrong with the page result.
247
+ // We don't want the user to have to parse the report to figure it out, so we'll still exit
248
+ // with an error code after we saved the results.
249
+ if (runnerResult?.lhr.runtimeError) {
250
+ const {runtimeError} = runnerResult.lhr;
251
+ throw Object.assign(new Error(runtimeError.message), {
252
+ name: 'LighthouseError',
253
+ friendlyMessage: runtimeError.message,
254
+ code: runtimeError.code,
255
+ stack: undefined,
256
+ });
257
+ }
258
+ } catch (err) {
259
+ errors.push(err);
260
+ await launchedChrome?.kill();
261
+ launchedChrome = undefined;
262
+ if (!isBatch || flags.failFast) break;
263
+ const errMsg = err.friendlyMessage || err.message;
264
+ log.error('CLI', `Runtime error encountered for ${url}: ${errMsg}`);
265
+ }
215
266
  }
216
-
217
- launchedChrome?.kill();
267
+ } finally {
268
+ await launchedChrome?.kill();
218
269
  process.removeListener('unhandledRejection', handleTheUnhandled);
270
+ }
219
271
 
220
- // Runtime errors indicate something was *very* wrong with the page result.
221
- // We don't want the user to have to parse the report to figure it out, so we'll still exit
222
- // with an error code after we saved the results.
223
- if (runnerResult?.lhr.runtimeError) {
224
- const {runtimeError} = runnerResult.lhr;
225
- return printErrorAndExit({
226
- name: 'LighthouseError',
227
- friendlyMessage: runtimeError.message,
228
- code: runtimeError.code,
229
- message: runtimeError.message,
230
- });
272
+ if (htmlReportPaths.length > 0) {
273
+ if (flags.view) {
274
+ for (const htmlPath of htmlReportPaths) {
275
+ open(htmlPath, {wait: false});
276
+ }
277
+ } else {
278
+ // eslint-disable-next-line max-len
279
+ log.log('CLI', 'Protip: Run lighthouse with `--view` to immediately open the HTML report in your browser');
231
280
  }
281
+ }
232
282
 
233
- return runnerResult;
234
- } catch (err) {
235
- launchedChrome?.kill();
236
- return printErrorAndExit(err);
283
+ if (errors.length > 0) {
284
+ return printErrorAndExit(errors[0]);
237
285
  }
286
+
287
+ return isBatch ? results : results[0];
238
288
  }
239
289
 
240
290
  export {
@@ -60,16 +60,15 @@ function prompt() {
60
60
  function askPermission() {
61
61
  return Promise.resolve().then(_ => {
62
62
  const configstore = new Configstore('lighthouse');
63
- let isErrorReportingEnabled = configstore.get('isErrorReportingEnabled');
63
+ const isErrorReportingEnabled = configstore.get('isErrorReportingEnabled');
64
64
  if (typeof isErrorReportingEnabled === 'boolean') {
65
65
  return Promise.resolve(isErrorReportingEnabled);
66
66
  }
67
67
 
68
68
  return prompt()
69
69
  .then(response => {
70
- isErrorReportingEnabled = response;
71
- configstore.set('isErrorReportingEnabled', isErrorReportingEnabled);
72
- return isErrorReportingEnabled;
70
+ configstore.set('isErrorReportingEnabled', response);
71
+ return response;
73
72
  });
74
73
  // Error accessing configstore; default to false.
75
74
  }).catch(_ => false);
@@ -1,4 +1,4 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
2
 
3
3
  exports[`getAssertionReport works (multiple failing) 1`] = `
4
4
  "X difference at cumulative-layout-shift audit.details.items.length
@@ -23,7 +23,7 @@ const exclusions = {
23
23
  'redirects-http',
24
24
  // Disabled because these tests use settings that cannot be fully configured in
25
25
  // DevTools (e.g. throttling method "provided").
26
- 'metrics-tricky-tti', 'metrics-tricky-tti-late-fcp', 'screenshot',
26
+ 'metrics-delayed-lcp', 'metrics-tricky-tti', 'metrics-tricky-tti-late-fcp', 'screenshot',
27
27
  // Disabled because of differences that need further investigation
28
28
  'byte-efficiency', 'byte-gzip', 'perf-preload',
29
29
  // Disabled because a renderer crash also breaks devtools frontend
@@ -78,6 +78,8 @@ const exclusions = {
78
78
  };
79
79
 
80
80
  for (const array of Object.values(exclusions)) {
81
+ // https://github.com/GoogleChrome/lighthouse/issues/17277
82
+ array.push('webmcp');
81
83
  // https://github.com/GoogleChrome/lighthouse/issues/14271
82
84
  array.push('lantern-idle-callback-short');
83
85
  // glitch is gone.
@@ -216,7 +216,9 @@ async function begin() {
216
216
  if (testDefnPath === coreTestDefnsPath) {
217
217
  const {createServers} = await import('../../fixtures/static-server.js');
218
218
  servers = await createServers();
219
- takeNetworkRequestUrls = servers[0].takeRequestUrls.bind(servers[0]);
219
+ if (servers.length) {
220
+ takeNetworkRequestUrls = servers[0].takeRequestUrls.bind(servers[0]);
221
+ }
220
222
  }
221
223
 
222
224
  const prunedTestDefns = pruneExpectedNetworkRequests(testDefns, takeNetworkRequestUrls);
@@ -234,7 +236,7 @@ async function begin() {
234
236
 
235
237
  smokehouseResult = (await runSmokehouse(prunedTestDefns, options));
236
238
  } finally {
237
- servers?.forEach(s => s.close());
239
+ if (servers) await Promise.all(servers.map(s => s.close()));
238
240
  }
239
241
 
240
242
  let smokehouseOutputDir;
@@ -244,7 +246,7 @@ async function begin() {
244
246
  smokehouseOutputDir = `${LH_ROOT}/.tmp/smokehouse-failures`;
245
247
  testResultsToOutput = smokehouseResult.testResults.filter(r => r.failed);
246
248
  } else if (!process.env.CI) {
247
- // Otherwise, only write to disk in debug mode.
249
+ // Otherwise, write all results to disk when running locally.
248
250
  smokehouseOutputDir = `${LH_ROOT}/.tmp/smokehouse-output`;
249
251
  testResultsToOutput = smokehouseResult.testResults;
250
252
  }
@@ -73,6 +73,31 @@ describe('findDiffersences', () => {
73
73
  expected: {duration: '<100'},
74
74
  diffs: [{path: '.duration', actual: 100, expected: '<100'}],
75
75
  },
76
+ 'range (negative 1)': {
77
+ actual: {duration: -3},
78
+ expected: {duration: '>-5'},
79
+ diffs: null,
80
+ },
81
+ 'range (negative 2)': {
82
+ actual: {duration: -9},
83
+ expected: {duration: '-10 +/- 2'},
84
+ diffs: null,
85
+ },
86
+ 'range (invalid numerical expectation without prefix)': {
87
+ actual: {duration: 3},
88
+ expected: {duration: '+/- 5'},
89
+ diffs: [{path: '.duration', actual: 3, expected: '+/- 5'}],
90
+ },
91
+ 'range (invalid numerical expectation with prefix)': {
92
+ actual: {duration: 15},
93
+ expected: {duration: '10 < 20'},
94
+ diffs: [{path: '.duration', actual: 15, expected: '10 < 20'}],
95
+ },
96
+ 'range (numeric actual vs array expected)': {
97
+ actual: {duration: 10},
98
+ expected: {duration: ['>0']},
99
+ diffs: [{path: '.duration', actual: 10, expected: ['>0']}],
100
+ },
76
101
 
77
102
  'array (1)': {
78
103
  actual: {prices: [0, 1, 2, 3, 4, 5]},
@@ -112,6 +137,11 @@ describe('findDiffersences', () => {
112
137
  expected: {prices: {'3': {nested: '>3'}}},
113
138
  diffs: [{path: '.prices[3].nested', actual: 3, expected: '>3'}],
114
139
  },
140
+ 'array (8)': {
141
+ actual: {prices: {0: 1, length: 1}},
142
+ expected: {prices: [1]},
143
+ diffs: [{path: '.prices.length', actual: {0: 1, length: 1}, expected: [1]}],
144
+ },
115
145
 
116
146
  '_includes (1)': {
117
147
  actual: {prices: [0, 1, 2, 3, 4, 5]},
@@ -295,4 +325,39 @@ describe('getAssertionReport', () => {
295
325
  expect(report).toMatchObject({passed: 3, failed: 1});
296
326
  expect(clean(report.log)).toMatchSnapshot();
297
327
  });
328
+
329
+ it('asserts expected.lhr.userAgent', () => {
330
+ const passingReport = getAssertionReport({lhr, artifacts}, {
331
+ lhr: {
332
+ audits: {},
333
+ requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
334
+ finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
335
+ userAgent: /Chrome\//,
336
+ },
337
+ });
338
+ expect(passingReport).toMatchObject({passed: 4, failed: 0, log: ''});
339
+
340
+ const failingReport = getAssertionReport({lhr, artifacts}, {
341
+ lhr: {
342
+ audits: {},
343
+ requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
344
+ finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
345
+ userAgent: 'NotTheUserAgent',
346
+ },
347
+ });
348
+ expect(failingReport).toMatchObject({passed: 3, failed: 1});
349
+ });
350
+
351
+ it('lazily evaluates hostUserAgent only when chromium version check is used', () => {
352
+ const customLhr = structuredClone(lhr);
353
+ customLhr.environment.hostUserAgent = 'InvalidUserAgentWithoutChromeVersion';
354
+ const report = getAssertionReport({lhr: customLhr, artifacts}, {
355
+ lhr: {
356
+ audits: {},
357
+ requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
358
+ finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
359
+ },
360
+ });
361
+ expect(report).toMatchObject({passed: 3, failed: 0, log: ''});
362
+ });
298
363
  });
@@ -30,7 +30,7 @@ import {chromiumVersionCheck} from './version-check.js';
30
30
  * @property {Difference[]|null} diffs
31
31
  */
32
32
 
33
- const NUMBER_REGEXP = /(?:\d|\.)+/.source;
33
+ const NUMBER_REGEXP = /-?(?:\d|\.)+/.source;
34
34
  const OPS_REGEXP = /<=?|>=?|\+\/-|±/.source;
35
35
  // An optional number, optional whitespace, an operator, optional whitespace, a number.
36
36
  const NUMERICAL_EXPECTATION_REGEXP =
@@ -48,21 +48,27 @@ const NUMERICAL_EXPECTATION_REGEXP =
48
48
  * @return {boolean}
49
49
  */
50
50
  function matchesExpectation(actual, expected) {
51
- if (typeof actual === 'number' && NUMERICAL_EXPECTATION_REGEXP.test(expected)) {
51
+ if (
52
+ typeof actual === 'number' &&
53
+ typeof expected === 'string' &&
54
+ NUMERICAL_EXPECTATION_REGEXP.test(expected)
55
+ ) {
52
56
  const parts = expected.match(NUMERICAL_EXPECTATION_REGEXP);
57
+ if (!parts) return false;
53
58
  const [, prefixNumber, operator, postfixNumber] = parts;
54
59
  switch (operator) {
55
60
  case '>':
56
- return actual > postfixNumber;
61
+ return prefixNumber === undefined && actual > Number(postfixNumber);
57
62
  case '>=':
58
- return actual >= postfixNumber;
63
+ return prefixNumber === undefined && actual >= Number(postfixNumber);
59
64
  case '<':
60
- return actual < postfixNumber;
65
+ return prefixNumber === undefined && actual < Number(postfixNumber);
61
66
  case '<=':
62
- return actual <= postfixNumber;
67
+ return prefixNumber === undefined && actual <= Number(postfixNumber);
63
68
  case '+/-':
64
69
  case '±':
65
- return Math.abs(actual - prefixNumber) <= postfixNumber;
70
+ return prefixNumber !== undefined &&
71
+ Math.abs(actual - Number(prefixNumber)) <= Number(postfixNumber);
66
72
  default:
67
73
  throw new Error(`unexpected operator ${operator}`);
68
74
  }
@@ -117,21 +123,8 @@ function findDifferences(path, actual, expected) {
117
123
  const expectedValue = expected[key];
118
124
 
119
125
  if (key === '_includes') {
120
- if (Array.isArray(actual)) {
121
- inclExclCopy = [...actual];
122
- } else if (typeof actual === 'object') {
123
- inclExclCopy = Object.entries(actual);
124
- }
125
-
126
126
  if (!Array.isArray(expectedValue)) throw new Error('Array subset must be array');
127
- if (!inclExclCopy) {
128
- diffs.push({
129
- path,
130
- actual: 'Actual value is not an array or object',
131
- expected,
132
- });
133
- continue;
134
- }
127
+ inclExclCopy = Array.isArray(actual) ? [...actual] : Object.entries(actual);
135
128
 
136
129
  for (const expectedEntry of expectedValue) {
137
130
  const matchingIndex =
@@ -153,24 +146,11 @@ function findDifferences(path, actual, expected) {
153
146
  }
154
147
 
155
148
  if (key === '_excludes') {
156
- // Re-use state from `_includes` check, if there was one.
157
- if (!inclExclCopy) {
158
- if (Array.isArray(actual)) {
159
- // We won't be removing items, so we can just copy the reference.
160
- inclExclCopy = actual;
161
- } else if (typeof actual === 'object') {
162
- inclExclCopy = Object.entries(actual);
163
- }
164
- }
165
-
166
149
  if (!Array.isArray(expectedValue)) throw new Error('Array subset must be array');
150
+ // Re-use state from `_includes` check, if there was one.
167
151
  if (!inclExclCopy) {
168
- diffs.push({
169
- path,
170
- actual: 'Actual value is not an array or object',
171
- expected,
172
- });
173
- continue;
152
+ // We won't be removing items, so we can just copy the reference.
153
+ inclExclCopy = Array.isArray(actual) ? actual : Object.entries(actual);
174
154
  }
175
155
 
176
156
  const expectedExclusions = expectedValue;
@@ -201,7 +181,7 @@ function findDifferences(path, actual, expected) {
201
181
  // This still allows for asserting that the first n elements of an array are specified elements,
202
182
  // but requires using an object literal (ex: {0: x, 1: y, 2: z} matches [x, y, z, q, w, e] and
203
183
  // {0: x, 1: y, 2: z, length: 5} does not match [x, y, z].
204
- if (Array.isArray(expected) && actual.length !== expected.length) {
184
+ if (Array.isArray(expected) && (!Array.isArray(actual) || actual.length !== expected.length)) {
205
185
  diffs.push({
206
186
  path: `${path}.length`,
207
187
  actual,
@@ -256,6 +236,7 @@ function pruneExpectations(localConsole, lhr, expected, reportOptions) {
256
236
  * @param {*} obj
257
237
  */
258
238
  function failsChromeVersionCheck(obj) {
239
+ if (!obj._minChromiumVersion && !obj._maxChromiumVersion) return false;
259
240
  return !chromiumVersionCheck({
260
241
  version: getChromeVersionString(),
261
242
  min: obj._minChromiumVersion,
@@ -376,6 +357,10 @@ function collateResults(localConsole, actual, expected) {
376
357
  /** @type {Comparison[]} */
377
358
  const extraAssertions = [];
378
359
 
360
+ if (expected.lhr.userAgent) {
361
+ extraAssertions.push(makeComparison('userAgent', actual.lhr.userAgent, expected.lhr.userAgent));
362
+ }
363
+
379
364
  if (expected.lhr.timing) {
380
365
  const comparison = makeComparison('timing', actual.lhr.timing, expected.lhr.timing);
381
366
  extraAssertions.push(comparison);
@@ -425,7 +410,7 @@ function reportAssertion(localConsole, assertion) {
425
410
  RegExp.prototype.toJSON = RegExp.prototype.toString;
426
411
 
427
412
  if (assertion.equal) {
428
- if (isPlainObject(assertion.actual)) {
413
+ if (isPlainObject(assertion.actual) || Array.isArray(assertion.actual)) {
429
414
  localConsole.log(` ${log.greenify(log.tick)} ${assertion.name}`);
430
415
  } else {
431
416
  localConsole.log(` ${log.greenify(log.tick)} ${assertion.name}: ` +
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=smokehouse-test.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as td from 'testdouble';
8
+
9
+ import {runSmokehouse} from './smokehouse.js';
10
+ import {ChildProcessError} from './lib/child-process-error.js';
11
+
12
+ /** @type {Smokehouse.TestDfn} */
13
+ const smokeTestDefn = {
14
+ id: 'test-smoke',
15
+ expectations: {
16
+ lhr: {
17
+ requestedUrl: 'http://localhost:10200/simple-page.html',
18
+ finalDisplayedUrl: 'http://localhost:10200/simple-page.html',
19
+ audits: {},
20
+ },
21
+ },
22
+ };
23
+
24
+ describe('runSmokehouse error logging', () => {
25
+ /** @type {string[]} */
26
+ let loggedMessages;
27
+
28
+ beforeEach(() => {
29
+ loggedMessages = [];
30
+ td.replace(console, 'log', (/** @type {unknown[]} */ ...args) => {
31
+ loggedMessages.push(args.map(String).join(' '));
32
+ });
33
+ });
34
+
35
+ afterEach(() => {
36
+ td.reset();
37
+ });
38
+
39
+ it('logs runner logger output when lighthouseRunner returns a malformed result', async () => {
40
+ /** @type {Smokehouse.LighthouseRunner} */
41
+ // @ts-expect-error - testing malformed runner result
42
+ const lighthouseRunner = async (_url, _config, logger) => {
43
+ logger?.log('runner progress before malformed result');
44
+ return {lhr: {}, artifacts: undefined};
45
+ };
46
+
47
+ const {success, testResults} = await runSmokehouse([smokeTestDefn], {
48
+ lighthouseRunner,
49
+ retries: 0,
50
+ });
51
+
52
+ expect(success).toBe(false);
53
+ expect(testResults).toHaveLength(1);
54
+ expect(testResults[0].failed).toBe(1);
55
+
56
+ const combinedOutput = loggedMessages.join('\n');
57
+ expect(combinedOutput).toContain('lighthouse runner returned a bad result');
58
+ expect(combinedOutput).toContain('runner progress before malformed result');
59
+ });
60
+
61
+ it('logs runner logger output when lighthouseRunner throws a regular Error', async () => {
62
+ /** @type {Smokehouse.LighthouseRunner} */
63
+ const lighthouseRunner = async (_url, _config, logger) => {
64
+ logger?.log('runner progress before throwing');
65
+ throw new Error('unexpected runner failure');
66
+ };
67
+
68
+ const {success, testResults} = await runSmokehouse([smokeTestDefn], {
69
+ lighthouseRunner,
70
+ retries: 0,
71
+ });
72
+
73
+ expect(success).toBe(false);
74
+ expect(testResults).toHaveLength(1);
75
+ expect(testResults[0].failed).toBe(1);
76
+
77
+ const combinedOutput = loggedMessages.join('\n');
78
+ expect(combinedOutput).toContain('unexpected runner failure');
79
+ expect(combinedOutput).toContain('runner progress before throwing');
80
+ });
81
+
82
+ it('does not duplicate runner logger output for ChildProcessError', async () => {
83
+ /** @type {Smokehouse.LighthouseRunner} */
84
+ const lighthouseRunner = async (_url, _config, logger) => {
85
+ logger?.log('unique-cli-runner-log-line');
86
+ throw new ChildProcessError('Lighthouse run failed', logger?.getLog());
87
+ };
88
+
89
+ const {success} = await runSmokehouse([smokeTestDefn], {
90
+ lighthouseRunner,
91
+ retries: 0,
92
+ });
93
+
94
+ expect(success).toBe(false);
95
+
96
+ const combinedOutput = loggedMessages.join('\n');
97
+ const occurrences = combinedOutput.split('unique-cli-runner-log-line').length - 1;
98
+ expect(occurrences).toBe(1);
99
+ });
100
+
101
+ it('does not duplicate runner logger output when Error message already includes it', async () => {
102
+ /** @type {Smokehouse.LighthouseRunner} */
103
+ const lighthouseRunner = async (_url, _config, logger) => {
104
+ logger?.log('unique-worker-runner-log-line');
105
+ const log = logger?.getLog() || '';
106
+ throw new Error(`Worker returned an error: boom\nLog:\n${log}\n`);
107
+ };
108
+
109
+ const {success} = await runSmokehouse([smokeTestDefn], {
110
+ lighthouseRunner,
111
+ retries: 0,
112
+ });
113
+
114
+ expect(success).toBe(false);
115
+
116
+ const combinedOutput = loggedMessages.join('\n');
117
+ const occurrences = combinedOutput.split('unique-worker-runner-log-line').length - 1;
118
+ expect(occurrences).toBe(1);
119
+ });
120
+ });