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 +2 -2
- package/cli/run.js +1 -1
- package/cli/test/smokehouse/smokehouse-test.d.ts +2 -0
- package/cli/test/smokehouse/smokehouse-test.js +120 -0
- package/cli/test/smokehouse/smokehouse.js +11 -3
- package/core/audits/script-treemap-data.js +18 -8
- package/core/computed/metrics/lantern-metric.js +1 -1
- package/core/computed/page-dependency-graph.js +55 -1
- package/core/lib/arbitrary-equality-map.d.ts +2 -2
- package/core/lib/arbitrary-equality-map.js +46 -6
- package/core/lib/legacy-javascript/legacy-javascript.js +2 -2
- package/dist/report/bundle.esm.js +1 -1
- package/dist/report/flow.js +1 -1
- package/dist/report/standalone.js +1 -1
- package/package.json +1 -1
- package/report/generator/file-namer.js +1 -1
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.
|
|
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.
|
|
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.
|
|
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,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
|
|
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
|
|
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(
|
|
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
|
|
182
|
-
|
|
181
|
+
const deduplicatedScripts = artifacts.Scripts.filter(script => {
|
|
182
|
+
if (script.scriptLanguage !== 'JavaScript') return false;
|
|
183
183
|
|
|
184
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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))
|
|
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
|
|
193
|
-
esShimsBranches.push(`${escapeRegExp(object)},\\s*{
|
|
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:
|