lighthouse 13.5.0-dev.20261022 → 13.5.0-dev.20261023

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.
package/cli/cli-flags.js CHANGED
@@ -328,7 +328,7 @@ Example: --output-path=./lighthouse-results.html`,
328
328
  if (
329
329
  typeof argv.outputPath === 'string' &&
330
330
  argv.outputPath &&
331
- (!fs.existsSync(argv.outputPath) || !fs.lstatSync(argv.outputPath).isDirectory())
331
+ (!fs.existsSync(argv.outputPath) || !fs.statSync(argv.outputPath).isDirectory())
332
332
  ) {
333
333
  throw new Error('--output-path must be an existing directory when auditing multiple URLs');
334
334
  }
@@ -459,7 +459,7 @@ function coerceUrlFile(value) {
459
459
  typeof value !== 'string' ||
460
460
  !value ||
461
461
  !fs.existsSync(value) ||
462
- fs.lstatSync(value).isDirectory()
462
+ fs.statSync(value).isDirectory()
463
463
  ) {
464
464
  throw new Error(`--url-file (${value}) cannot be read`);
465
465
  }
package/cli/run.js CHANGED
@@ -149,7 +149,7 @@ async function saveResults(runnerResult, flags) {
149
149
  const isDirectoryOutput = Boolean(
150
150
  flags.outputPath &&
151
151
  fs.existsSync(flags.outputPath) &&
152
- fs.lstatSync(flags.outputPath).isDirectory()
152
+ fs.statSync(flags.outputPath).isDirectory()
153
153
  );
154
154
 
155
155
  // Use the output path as the prefix for all generated files.
@@ -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
+ });
@@ -190,10 +190,10 @@ async function runSmokeTest(smokeTestDefn, testOptions) {
190
190
  clearTimeout(timeoutId);
191
191
  }
192
192
 
193
- if (!timedResult.lhr?.audits || !timedResult.artifacts) {
193
+ if (!timedResult?.lhr?.audits || !timedResult.artifacts) {
194
194
  // Something went really wrong and the runner didn't catch it.
195
195
  throw new Error('lighthouse runner returned a bad result. got lhr:\n' +
196
- JSON.stringify(timedResult.lhr, null, 2));
196
+ JSON.stringify(timedResult?.lhr, null, 2));
197
197
  }
198
198
 
199
199
  result = {
@@ -206,9 +206,17 @@ async function runSmokeTest(smokeTestDefn, testOptions) {
206
206
  if (takeNetworkRequestUrls) takeNetworkRequestUrls();
207
207
 
208
208
  logChildProcessError(bufferedConsole, e);
209
+ const runnerLog = logger.getLog();
209
210
  if (isTimeout) {
210
211
  bufferedConsole.log('Timed out. log from lighthouseRunner:');
211
- bufferedConsole.log(logger.getLog());
212
+ bufferedConsole.log(runnerLog);
213
+ } else if (
214
+ !(e instanceof ChildProcessError) &&
215
+ runnerLog &&
216
+ !e.message?.includes(runnerLog)
217
+ ) {
218
+ bufferedConsole.log('log from lighthouseRunner:');
219
+ bufferedConsole.log(runnerLog);
212
220
  }
213
221
  continue; // Retry, if possible.
214
222
  }
@@ -178,21 +178,31 @@ class ScriptTreemapDataAudit extends Audit {
178
178
  const htmlNodesByFrameId = new Map();
179
179
  /** @type {Set<LH.Artifacts.NetworkRequest>} */
180
180
  const seenNetworkRecords = new Set();
181
- const bundles = await JSBundles.request(artifacts, context);
182
- const duplicationByPath = await ModuleDuplication.request(artifacts, context);
181
+ const deduplicatedScripts = artifacts.Scripts.filter(script => {
182
+ if (script.scriptLanguage !== 'JavaScript') return false;
183
183
 
184
- for (const script of artifacts.Scripts) {
185
- if (script.scriptLanguage !== 'JavaScript') continue;
186
-
187
- const isInlineScript = isInline(script);
188
- const networkRecord = isInlineScript ?
184
+ const networkRecord = isInline(script) ?
189
185
  undefined :
190
186
  getRequestForScript(networkRecords, script);
191
187
  if (networkRecord) {
192
- if (seenNetworkRecords.has(networkRecord)) continue;
188
+ if (seenNetworkRecords.has(networkRecord)) return false;
193
189
  seenNetworkRecords.add(networkRecord);
194
190
  }
195
191
 
192
+ return true;
193
+ });
194
+ const deduplicatedArtifacts = deduplicatedScripts.length === artifacts.Scripts.length ?
195
+ artifacts :
196
+ {...artifacts, Scripts: deduplicatedScripts};
197
+ const bundles = await JSBundles.request(deduplicatedArtifacts, context);
198
+ const duplicationByPath = await ModuleDuplication.request(deduplicatedArtifacts, context);
199
+
200
+ for (const script of deduplicatedScripts) {
201
+ const isInlineScript = isInline(script);
202
+ const networkRecord = isInlineScript ?
203
+ undefined :
204
+ getRequestForScript(networkRecords, script);
205
+
196
206
  const name = script.url;
197
207
  const bundle = bundles.find(bundle => script.scriptId === bundle.script.scriptId) ?? null;
198
208
  const scriptCoverage = /** @type {LH.Artifacts['JsUsage'][string] | undefined} */
@@ -40,7 +40,7 @@ async function getComputationDataParamsFromTrace(data, context) {
40
40
  const traceEngineResult = await TraceEngineResult.request(data, context);
41
41
  const frameId = traceEngineResult.data.Meta.mainFrameId;
42
42
  const navigation =
43
- traceEngineResult.data.Meta.mainFrameNavigations[0];
43
+ traceEngineResult.data.Meta.mainFrameNavigations.at(-1);
44
44
  if (!navigation) {
45
45
  throw new Error(`Lantern metrics could not be calculated due to missing navigation id`);
46
46
  }
@@ -11,6 +11,26 @@ import {ProcessedTrace} from './processed-trace.js';
11
11
  import {NetworkRecords} from './network-records.js';
12
12
  import {TraceEngineResult} from './trace-engine-result.js';
13
13
 
14
+ /**
15
+ * ResourceSendRequest URLs in the trace retain #hash fragments, whereas CDP
16
+ * Network.requestWillBeSent, Page.frameNavigated, initiator.url, documentURL,
17
+ * and V8 script events strip #hash fragments, and Lantern's NetworkAnalyzer
18
+ * requires request.url not to have a fragment.
19
+ * TODO: Remove once @paulirish/trace_engine includes the fix in LanternComputationData.
20
+ * @param {string} urlStr
21
+ * @return {string}
22
+ */
23
+ function stripUrlHash(urlStr) {
24
+ if (!urlStr.includes('#')) return urlStr;
25
+ try {
26
+ const url = new URL(urlStr);
27
+ url.hash = '';
28
+ return url.href;
29
+ } catch {
30
+ return urlStr;
31
+ }
32
+ }
33
+
14
34
  class PageDependencyGraph {
15
35
  /**
16
36
  * @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog, settings: LH.Audit.Context['settings'], URL: LH.Artifacts['URL'], SourceMaps: LH.Artifacts['SourceMaps'], HostDPR: LH.Artifacts['HostDPR'], fromTrace: boolean}} data
@@ -27,7 +47,41 @@ class PageDependencyGraph {
27
47
  if (data.fromTrace) {
28
48
  const traceEngineResult =
29
49
  await TraceEngineResult.request({trace, settings, SourceMaps, HostDPR}, context);
30
- const parsedTrace = traceEngineResult.data;
50
+ let parsedTrace = traceEngineResult.data;
51
+ if (parsedTrace.NetworkRequests.byTime.some(
52
+ r => r.args.data.url.includes('#') ||
53
+ r.args.data.redirects.some(red => red.url.includes('#'))
54
+ )) {
55
+ const byTime = parsedTrace.NetworkRequests.byTime.map(request => {
56
+ const url = stripUrlHash(request.args.data.url);
57
+ const redirects = request.args.data.redirects.map(redirect => ({
58
+ ...redirect,
59
+ url: stripUrlHash(redirect.url),
60
+ }));
61
+ if (url === request.args.data.url &&
62
+ redirects.every((red, i) => red.url === request.args.data.redirects[i].url)) {
63
+ return request;
64
+ }
65
+ return {
66
+ ...request,
67
+ args: {
68
+ ...request.args,
69
+ data: {
70
+ ...request.args.data,
71
+ url,
72
+ redirects,
73
+ },
74
+ },
75
+ };
76
+ });
77
+ parsedTrace = {
78
+ ...parsedTrace,
79
+ NetworkRequests: {
80
+ ...parsedTrace.NetworkRequests,
81
+ byTime,
82
+ },
83
+ };
84
+ }
31
85
  const requests =
32
86
  Lantern.TraceEngineComputationData.createNetworkRequests(trace, parsedTrace);
33
87
  const graph =
@@ -50,9 +50,9 @@ export class ArbitraryEqualityMap {
50
50
  /**
51
51
  * @param {*} a
52
52
  * @param {*} b
53
- * @param {WeakMap<object, object>=} seen
53
+ * @param {WeakMap<object, WeakSet<object>>=} seen
54
54
  * @return {boolean}
55
55
  */
56
- declare function deepEquals(a: any, b: any, seen?: WeakMap<object, object> | undefined): boolean;
56
+ declare function deepEquals(a: any, b: any, seen?: WeakMap<object, WeakSet<object>> | undefined): boolean;
57
57
  export { deepEquals as isEqual };
58
58
  //# sourceMappingURL=arbitrary-equality-map.d.ts.map
@@ -7,7 +7,7 @@
7
7
  /**
8
8
  * @param {*} a
9
9
  * @param {*} b
10
- * @param {WeakMap<object, object>=} seen
10
+ * @param {WeakMap<object, WeakSet<object>>=} seen
11
11
  * @return {boolean}
12
12
  */
13
13
  function deepEquals(a, b, seen) {
@@ -32,13 +32,27 @@ function deepEquals(a, b, seen) {
32
32
  }
33
33
  return true;
34
34
  }
35
+ if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) {
36
+ if (a.byteLength !== b.byteLength) return false;
37
+ const viewA = new Uint8Array(a);
38
+ const viewB = new Uint8Array(b);
39
+ for (let i = 0; i < viewA.length; i++) {
40
+ if (viewA[i] !== viewB[i]) return false;
41
+ }
42
+ return true;
43
+ }
35
44
 
36
- if (seen) {
37
- if (seen.get(a) === b) return true;
38
- } else {
45
+ if (!seen) {
39
46
  seen = new WeakMap();
40
47
  }
41
- seen.set(a, b);
48
+ let seenB = seen.get(a);
49
+ if (seenB) {
50
+ if (seenB.has(b)) return true;
51
+ } else {
52
+ seenB = new WeakSet();
53
+ seen.set(a, seenB);
54
+ }
55
+ seenB.add(b);
42
56
 
43
57
  if (Array.isArray(a)) {
44
58
  const arrB = /** @type {unknown[]} */ (b);
@@ -59,12 +73,38 @@ function deepEquals(a, b, seen) {
59
73
 
60
74
  if (a instanceof Set && b instanceof Set) {
61
75
  if (a.size !== b.size) return false;
76
+ /** @type {unknown[]} */
77
+ const unmatchedA = [];
62
78
  for (const v of a) {
63
- if (!b.has(v)) return false;
79
+ if (!b.has(v)) {
80
+ if (typeof v !== 'object' || v === null) return false;
81
+ unmatchedA.push(v);
82
+ }
83
+ }
84
+ if (unmatchedA.length === 0) return true;
85
+ /** @type {unknown[]} */
86
+ const unmatchedB = [];
87
+ for (const v of b) {
88
+ if (!a.has(v)) {
89
+ if (typeof v !== 'object' || v === null) return false;
90
+ unmatchedB.push(v);
91
+ }
92
+ }
93
+ if (unmatchedA.length !== unmatchedB.length) return false;
94
+ for (const itemA of unmatchedA) {
95
+ const matchIndex = unmatchedB.findIndex(itemB => deepEquals(itemA, itemB, seen));
96
+ if (matchIndex === -1) return false;
97
+ unmatchedB.splice(matchIndex, 1);
64
98
  }
65
99
  return true;
66
100
  }
67
101
 
102
+ if (a instanceof Error && b instanceof Error) {
103
+ if (a.name !== b.name || a.message !== b.message || !deepEquals(a.cause, b.cause, seen)) {
104
+ return false;
105
+ }
106
+ }
107
+
68
108
  const keysA = Object.keys(a);
69
109
  const keysB = Object.keys(b);
70
110
  if (keysA.length !== keysB.length) return false;
@@ -189,8 +189,8 @@ function getPolyfillPatterns() {
189
189
  // 4. es-shims: `no(Object,{entries:r},{entries:function`
190
190
  const esShimsBranches = [];
191
191
  for (const [object, props] of byObject) {
192
- const propAlt = `(?:${props.join('|')})`;
193
- esShimsBranches.push(`${escapeRegExp(object)},\\s*{${propAlt}:.*},\\s*{${propAlt}`);
192
+ const propBranches = props.map(p => `${p}:[^};]*},\\s*{${p}\\b`).join('|');
193
+ esShimsBranches.push(`${escapeRegExp(object)},\\s*{(?:${propBranches})`);
194
194
  }
195
195
 
196
196
  // 5. core-js@3 minified pattern: