lighthouse 13.5.0-dev.20261009 → 13.5.0-dev.20261012
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 +0 -1
- package/cli/sentry-prompt.js +3 -4
- package/core/audits/screenshot-thumbnails.d.ts +0 -13
- package/core/audits/screenshot-thumbnails.js +0 -45
- package/core/audits/unsized-images.d.ts +0 -7
- package/core/audits/unsized-images.js +7 -26
- package/core/computed/speedline.js +20 -5
- package/core/gather/gatherers/image-elements.d.ts +7 -0
- package/core/gather/gatherers/image-elements.js +75 -24
- package/dist/report/flow.js +13 -13
- package/package.json +1 -3
- package/types/artifacts.d.ts +6 -1
package/cli/cli-flags.js
CHANGED
|
@@ -20,7 +20,6 @@ import {isObjectOfUnknownValues} from '../shared/type-verifiers.js';
|
|
|
20
20
|
*/
|
|
21
21
|
function getYargsParser(manualArgv) {
|
|
22
22
|
const y = manualArgv ?
|
|
23
|
-
// @ts-expect-error - undocumented, but yargs() supports parsing a single `string`.
|
|
24
23
|
yargs(manualArgv) :
|
|
25
24
|
yargs(yargsHelpers.hideBin(process.argv));
|
|
26
25
|
|
package/cli/sentry-prompt.js
CHANGED
|
@@ -60,16 +60,15 @@ function prompt() {
|
|
|
60
60
|
function askPermission() {
|
|
61
61
|
return Promise.resolve().then(_ => {
|
|
62
62
|
const configstore = new Configstore('lighthouse');
|
|
63
|
-
|
|
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
|
|
71
|
-
|
|
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);
|
|
@@ -2,19 +2,6 @@ export default ScreenshotThumbnails;
|
|
|
2
2
|
export type SpeedlineFrame = LH.Artifacts.Speedline["frames"][0];
|
|
3
3
|
/** @typedef {LH.Artifacts.Speedline['frames'][0]} SpeedlineFrame */
|
|
4
4
|
declare class ScreenshotThumbnails extends Audit {
|
|
5
|
-
/**
|
|
6
|
-
* Scales down an image to THUMBNAIL_WIDTH using nearest neighbor for speed, maintains aspect
|
|
7
|
-
* ratio of the original thumbnail.
|
|
8
|
-
*
|
|
9
|
-
* @param {ReturnType<SpeedlineFrame['getParsedImage']>} imageData
|
|
10
|
-
* @param {number} scaledWidth
|
|
11
|
-
* @return {{width: number, height: number, data: Uint8Array}}
|
|
12
|
-
*/
|
|
13
|
-
static scaleImageToThumbnail(imageData: ReturnType<SpeedlineFrame["getParsedImage"]>, scaledWidth: number): {
|
|
14
|
-
width: number;
|
|
15
|
-
height: number;
|
|
16
|
-
data: Uint8Array;
|
|
17
|
-
};
|
|
18
5
|
/**
|
|
19
6
|
* @param {LH.Artifacts} artifacts
|
|
20
7
|
* @param {LH.Audit.Context} context
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import jpeg from 'jpeg-js';
|
|
8
|
-
|
|
9
7
|
import {Audit} from './audit.js';
|
|
10
8
|
import {LighthouseError} from '../lib/lh-error.js';
|
|
11
9
|
import {Speedline} from '../computed/speedline.js';
|
|
@@ -28,42 +26,6 @@ class ScreenshotThumbnails extends Audit {
|
|
|
28
26
|
};
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Scales down an image to THUMBNAIL_WIDTH using nearest neighbor for speed, maintains aspect
|
|
33
|
-
* ratio of the original thumbnail.
|
|
34
|
-
*
|
|
35
|
-
* @param {ReturnType<SpeedlineFrame['getParsedImage']>} imageData
|
|
36
|
-
* @param {number} scaledWidth
|
|
37
|
-
* @return {{width: number, height: number, data: Uint8Array}}
|
|
38
|
-
*/
|
|
39
|
-
static scaleImageToThumbnail(imageData, scaledWidth) {
|
|
40
|
-
const scaleFactor = imageData.width / scaledWidth;
|
|
41
|
-
const scaledHeight = Math.floor(imageData.height / scaleFactor);
|
|
42
|
-
|
|
43
|
-
const outPixels = new Uint8Array(scaledWidth * scaledHeight * 4);
|
|
44
|
-
|
|
45
|
-
for (let i = 0; i < scaledWidth; i++) {
|
|
46
|
-
for (let j = 0; j < scaledHeight; j++) {
|
|
47
|
-
const origX = Math.floor(i * scaleFactor);
|
|
48
|
-
const origY = Math.floor(j * scaleFactor);
|
|
49
|
-
|
|
50
|
-
const origPos = (origY * imageData.width + origX) * 4;
|
|
51
|
-
const outPos = (j * scaledWidth + i) * 4;
|
|
52
|
-
|
|
53
|
-
outPixels[outPos] = imageData.data[origPos];
|
|
54
|
-
outPixels[outPos + 1] = imageData.data[origPos + 1];
|
|
55
|
-
outPixels[outPos + 2] = imageData.data[origPos + 2];
|
|
56
|
-
outPixels[outPos + 3] = imageData.data[origPos + 3];
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
width: scaledWidth,
|
|
62
|
-
height: scaledHeight,
|
|
63
|
-
data: outPixels,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
29
|
/**
|
|
68
30
|
* @param {LH.Artifacts} artifacts
|
|
69
31
|
* @param {LH.Audit.Context} context
|
|
@@ -79,7 +41,6 @@ class ScreenshotThumbnails extends Audit {
|
|
|
79
41
|
// Make the minimum time range 3s so sites that load super quickly don't get a single screenshot
|
|
80
42
|
const minimumTimelineDuration = context.options.minimumTimelineDuration || 3000;
|
|
81
43
|
const numberOfThumbnails = context.options.numberOfThumbnails || NUMBER_OF_THUMBNAILS;
|
|
82
|
-
const thumbnailWidth = context.options.thumbnailWidth || null;
|
|
83
44
|
|
|
84
45
|
const thumbnails = [];
|
|
85
46
|
const analyzedFrames = speedline.frames.filter(frame => !frame.isProgressInterpolated());
|
|
@@ -112,12 +73,6 @@ class ScreenshotThumbnails extends Audit {
|
|
|
112
73
|
const cachedThumbnail = cachedThumbnails.get(frameForTimestamp);
|
|
113
74
|
if (cachedThumbnail) {
|
|
114
75
|
base64Data = cachedThumbnail;
|
|
115
|
-
} else if (thumbnailWidth !== null) {
|
|
116
|
-
const imageData = frameForTimestamp.getParsedImage();
|
|
117
|
-
const thumbnailImageData =
|
|
118
|
-
ScreenshotThumbnails.scaleImageToThumbnail(imageData, thumbnailWidth);
|
|
119
|
-
base64Data = jpeg.encode(thumbnailImageData, 90).data.toString('base64');
|
|
120
|
-
cachedThumbnails.set(frameForTimestamp, base64Data);
|
|
121
76
|
} else {
|
|
122
77
|
base64Data = frameForTimestamp.getImage().toString('base64');
|
|
123
78
|
cachedThumbnails.set(frameForTimestamp, base64Data);
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
export default UnsizedImages;
|
|
2
2
|
declare class UnsizedImages extends Audit {
|
|
3
|
-
/**
|
|
4
|
-
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
5
|
-
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
6
|
-
* @param {string | null} attrValue
|
|
7
|
-
* @return {boolean}
|
|
8
|
-
*/
|
|
9
|
-
static doesHtmlAttrProvideExplicitSize(attrValue: string | null): boolean;
|
|
10
3
|
/**
|
|
11
4
|
* An img css size property prevents layout shifts if it is defined, not empty, and not equal to 'auto'.
|
|
12
5
|
* @param {string | null} property
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
import {Audit} from './audit.js';
|
|
13
|
+
import ImageElements from '../gather/gatherers/image-elements.js';
|
|
13
14
|
import * as i18n from './../lib/i18n/i18n.js';
|
|
14
15
|
import UrlUtils from '../lib/url-utils.js';
|
|
15
16
|
|
|
@@ -40,28 +41,6 @@ class UnsizedImages extends Audit {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
45
|
-
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
46
|
-
* @param {string | null} attrValue
|
|
47
|
-
* @return {boolean}
|
|
48
|
-
*/
|
|
49
|
-
static doesHtmlAttrProvideExplicitSize(attrValue) {
|
|
50
|
-
if (!attrValue) return false;
|
|
51
|
-
|
|
52
|
-
// First, superweird edge case of using the positive-sign. The spec _sorta_ says it's valid...
|
|
53
|
-
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-integers
|
|
54
|
-
// > Otherwise, if the character is … (+): Advance position to the next character.
|
|
55
|
-
// > (The "+" is ignored, but it is not conforming.)
|
|
56
|
-
// lol. Irrelevant though b/c Chrome (at least) doesn't ignore. It rejects this as a non-conforming value.
|
|
57
|
-
if (attrValue.startsWith('+')) return false;
|
|
58
|
-
|
|
59
|
-
// parseInt isn't exactly the same as the html's spec for parsing integers, but it's close enough
|
|
60
|
-
// https://tc39.es/ecma262/#sec-parseint-string-radix
|
|
61
|
-
const int = parseInt(attrValue, 10);
|
|
62
|
-
return int >= 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
44
|
/**
|
|
66
45
|
* An img css size property prevents layout shifts if it is defined, not empty, and not equal to 'auto'.
|
|
67
46
|
* @param {string | null} property
|
|
@@ -78,6 +57,12 @@ class UnsizedImages extends Audit {
|
|
|
78
57
|
* @return {boolean}
|
|
79
58
|
*/
|
|
80
59
|
static isSizedImage(image) {
|
|
60
|
+
const attrWidth = image.attributeWidth;
|
|
61
|
+
const attrHeight = image.attributeHeight;
|
|
62
|
+
const htmlWidthIsExplicit = ImageElements.doesHtmlAttrProvideExplicitSize(attrWidth);
|
|
63
|
+
const htmlHeightIsExplicit = ImageElements.doesHtmlAttrProvideExplicitSize(attrHeight);
|
|
64
|
+
if (htmlWidthIsExplicit && htmlHeightIsExplicit) return true;
|
|
65
|
+
|
|
81
66
|
// Perhaps we hit reachedGatheringBudget before collecting this image's cssWidth/Height
|
|
82
67
|
// in fetchSourceRules. In this case, we don't have enough information to determine if it's sized.
|
|
83
68
|
// We don't want to show the user a false positive, so we'll call it sized to give it as pass.
|
|
@@ -85,14 +70,10 @@ class UnsizedImages extends Audit {
|
|
|
85
70
|
// Handwavey TODO: explore ways to avoid this.
|
|
86
71
|
if (image.cssEffectiveRules === undefined) return true;
|
|
87
72
|
|
|
88
|
-
const attrWidth = image.attributeWidth;
|
|
89
|
-
const attrHeight = image.attributeHeight;
|
|
90
73
|
const cssWidth = image.cssEffectiveRules.width;
|
|
91
74
|
const cssHeight = image.cssEffectiveRules.height;
|
|
92
75
|
const cssAspectRatio = image.cssEffectiveRules.aspectRatio;
|
|
93
|
-
const htmlWidthIsExplicit = UnsizedImages.doesHtmlAttrProvideExplicitSize(attrWidth);
|
|
94
76
|
const cssWidthIsExplicit = UnsizedImages.isCssPropExplicitlySet(cssWidth);
|
|
95
|
-
const htmlHeightIsExplicit = UnsizedImages.doesHtmlAttrProvideExplicitSize(attrHeight);
|
|
96
77
|
const cssHeightIsExplicit = UnsizedImages.isCssPropExplicitlySet(cssHeight);
|
|
97
78
|
const explicitAspectRatio = UnsizedImages.isCssPropExplicitlySet(cssAspectRatio);
|
|
98
79
|
const explicitWidth = htmlWidthIsExplicit || cssWidthIsExplicit;
|
|
@@ -20,13 +20,10 @@ class Speedline {
|
|
|
20
20
|
// speedline() may throw without a promise, so we resolve immediately
|
|
21
21
|
// to get in a promise chain.
|
|
22
22
|
return ProcessedTrace.request(trace, context).then(processedTrace => {
|
|
23
|
-
// Use a shallow copy of traceEvents so speedline can sort as it pleases.
|
|
24
|
-
// See https://github.com/GoogleChrome/lighthouse/issues/2333
|
|
25
|
-
const traceEvents = trace.traceEvents.slice();
|
|
26
23
|
// Force use of timeOrigin as reference point for speedline
|
|
27
24
|
// See https://github.com/GoogleChrome/lighthouse/issues/2095
|
|
28
25
|
const timeOrigin = processedTrace.timestamps.timeOrigin;
|
|
29
|
-
return speedline(traceEvents, {
|
|
26
|
+
return speedline(trace.traceEvents, {
|
|
30
27
|
timeOrigin,
|
|
31
28
|
fastMode: true,
|
|
32
29
|
include: 'speedIndex',
|
|
@@ -46,7 +43,25 @@ class Speedline {
|
|
|
46
43
|
throw new LighthouseError(LighthouseError.errors.SPEEDINDEX_OF_ZERO);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
// Evict the ~1 MB per-frame decoded RGBA buffers and histograms cached inside
|
|
47
|
+
// speedline-core's frame closures, keeping only the getters Lighthouse uses.
|
|
48
|
+
// TODO(https://github.com/paulirish/speedline/issues/122): Upstream to speedline-core
|
|
49
|
+
// so `frame()` in `lib/frame.js` does not cache `_parsedImage` on frame closures.
|
|
50
|
+
const frames = speedline.frames.map(frame => {
|
|
51
|
+
const imgBuff = frame.getImage();
|
|
52
|
+
const timeStamp = frame.getTimeStamp();
|
|
53
|
+
const isProgressInterpolated = frame.isProgressInterpolated();
|
|
54
|
+
return {
|
|
55
|
+
getImage: () => imgBuff,
|
|
56
|
+
getTimeStamp: () => timeStamp,
|
|
57
|
+
isProgressInterpolated: () => isProgressInterpolated,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...speedline,
|
|
63
|
+
frames,
|
|
64
|
+
};
|
|
50
65
|
});
|
|
51
66
|
}
|
|
52
67
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export default ImageElements;
|
|
2
2
|
declare class ImageElements extends BaseGatherer {
|
|
3
|
+
/**
|
|
4
|
+
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
5
|
+
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
6
|
+
* @param {string | null} attrValue
|
|
7
|
+
* @return {boolean}
|
|
8
|
+
*/
|
|
9
|
+
static doesHtmlAttrProvideExplicitSize(attrValue: string | null): boolean;
|
|
3
10
|
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
|
|
4
11
|
_naturalSizeCache: Map<string, {
|
|
5
12
|
naturalWidth: number;
|
|
@@ -238,6 +238,21 @@ function getEffectiveSizingRule({attributesStyle, inlineStyle, matchedCSSRules},
|
|
|
238
238
|
return null;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* @param {LH.Artifacts.ImageElement} element
|
|
243
|
+
* @return {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function needsSourceRules(element) {
|
|
246
|
+
if (element.isInShadowDOM || element.isCss) return false;
|
|
247
|
+
if (
|
|
248
|
+
ImageElements.doesHtmlAttrProvideExplicitSize(element.attributeWidth) &&
|
|
249
|
+
ImageElements.doesHtmlAttrProvideExplicitSize(element.attributeHeight)
|
|
250
|
+
) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
241
256
|
/**
|
|
242
257
|
* @param {LH.Artifacts.ImageElement} element
|
|
243
258
|
* @return {number}
|
|
@@ -255,6 +270,28 @@ class ImageElements extends BaseGatherer {
|
|
|
255
270
|
supportedModes: ['snapshot', 'timespan', 'navigation'],
|
|
256
271
|
};
|
|
257
272
|
|
|
273
|
+
/**
|
|
274
|
+
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
275
|
+
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
276
|
+
* @param {string | null} attrValue
|
|
277
|
+
* @return {boolean}
|
|
278
|
+
*/
|
|
279
|
+
static doesHtmlAttrProvideExplicitSize(attrValue) {
|
|
280
|
+
if (!attrValue) return false;
|
|
281
|
+
|
|
282
|
+
// First, superweird edge case of using the positive-sign. The spec _sorta_ says it's valid...
|
|
283
|
+
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-integers
|
|
284
|
+
// > Otherwise, if the character is … (+): Advance position to the next character.
|
|
285
|
+
// > (The "+" is ignored, but it is not conforming.)
|
|
286
|
+
// lol. Irrelevant though b/c Chrome (at least) doesn't ignore. It rejects this as a non-conforming value.
|
|
287
|
+
if (attrValue.startsWith('+')) return false;
|
|
288
|
+
|
|
289
|
+
// parseInt isn't exactly the same as the html's spec for parsing integers, but it's close enough
|
|
290
|
+
// https://tc39.es/ecma262/#sec-parseint-string-radix
|
|
291
|
+
const int = parseInt(attrValue, 10);
|
|
292
|
+
return int >= 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
258
295
|
constructor() {
|
|
259
296
|
super();
|
|
260
297
|
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
|
|
@@ -322,24 +359,32 @@ class ImageElements extends BaseGatherer {
|
|
|
322
359
|
async collectExtraDetails(driver, elements) {
|
|
323
360
|
// Don't do more than 5s of this expensive devtools protocol work. See #11289
|
|
324
361
|
let reachedGatheringBudget = false;
|
|
325
|
-
setTimeout(_ => (reachedGatheringBudget = true), 5000);
|
|
362
|
+
const timeoutId = setTimeout(_ => (reachedGatheringBudget = true), 5000);
|
|
326
363
|
let skippedCount = 0;
|
|
327
364
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
365
|
+
try {
|
|
366
|
+
for (const element of elements) {
|
|
367
|
+
if (reachedGatheringBudget) {
|
|
368
|
+
skippedCount++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Need source rules to determine if sized via CSS (for unsized-images).
|
|
373
|
+
if (needsSourceRules(element)) {
|
|
374
|
+
await this.fetchSourceRules(
|
|
375
|
+
driver.defaultSession,
|
|
376
|
+
element.node.devtoolsNodePath,
|
|
377
|
+
element
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
// Images within `picture` behave strangely and natural size information isn't accurate,
|
|
381
|
+
// CSS images have no natural size information at all. Try to get the actual size if we can.
|
|
382
|
+
if (element.isPicture || element.isCss || element.srcset) {
|
|
383
|
+
await this.fetchElementWithSizeInformation(driver, element);
|
|
384
|
+
}
|
|
342
385
|
}
|
|
386
|
+
} finally {
|
|
387
|
+
clearTimeout(timeoutId);
|
|
343
388
|
}
|
|
344
389
|
|
|
345
390
|
if (reachedGatheringBudget) {
|
|
@@ -369,11 +414,14 @@ class ImageElements extends BaseGatherer {
|
|
|
369
414
|
],
|
|
370
415
|
});
|
|
371
416
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
417
|
+
const shouldFetchSourceRules = elements.some(needsSourceRules);
|
|
418
|
+
if (shouldFetchSourceRules) {
|
|
419
|
+
await Promise.all([
|
|
420
|
+
session.sendCommand('DOM.enable'),
|
|
421
|
+
session.sendCommand('CSS.enable'),
|
|
422
|
+
session.sendCommand('DOM.getDocument', {depth: -1, pierce: true}),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
377
425
|
|
|
378
426
|
// Spend our extra details budget on highest impact images.
|
|
379
427
|
// Our best approximation of impact without network records is to use pixel area.
|
|
@@ -381,11 +429,14 @@ class ImageElements extends BaseGatherer {
|
|
|
381
429
|
|
|
382
430
|
await this.collectExtraDetails(context.driver, elements);
|
|
383
431
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
432
|
+
if (shouldFetchSourceRules) {
|
|
433
|
+
await Promise.all([
|
|
434
|
+
session.sendCommand('DOM.disable'),
|
|
435
|
+
session.sendCommand('CSS.disable'),
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
388
438
|
|
|
439
|
+
this._naturalSizeCache.clear();
|
|
389
440
|
return elements;
|
|
390
441
|
}
|
|
391
442
|
}
|