forked from GoogleChrome/lighthouse
-
Notifications
You must be signed in to change notification settings - Fork 0
/
full-page-screenshot.js
254 lines (223 loc) · 9.23 KB
/
full-page-screenshot.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
/**
* @license Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* globals window getBoundingClientRect requestAnimationFrame */
import BaseGatherer from '../base-gatherer.js';
import * as emulation from '../../lib/emulation.js';
import {pageFunctions} from '../../lib/page-functions.js';
import {waitForNetworkIdle} from '../driver/wait-for-condition.js';
// JPEG quality setting
// Exploration and examples of reports using different quality settings: https://docs.google.com/document/d/1ZSffucIca9XDW2eEwfoevrk-OTl7WQFeMf0CgeJAA8M/edit#
// Note: this analysis was done for JPEG, but now we use WEBP.
const FULL_PAGE_SCREENSHOT_QUALITY = 30;
// https://developers.google.com/speed/webp/faq#what_is_the_maximum_size_a_webp_image_can_be
const MAX_WEBP_SIZE = 16383;
/**
* @template {string} S
* @param {S} str
*/
function kebabCaseToCamelCase(str) {
return /** @type {LH.Util.KebabToCamelCase<S>} */ (
str.replace(/(-\w)/g, m => m[1].toUpperCase())
);
}
/* c8 ignore start */
function getObservedDeviceMetrics() {
// Convert the Web API's kebab case (landscape-primary) to camel case (landscapePrimary).
const screenOrientationType = kebabCaseToCamelCase(window.screen.orientation.type);
return {
width: window.outerWidth,
height: window.outerHeight,
screenOrientation: {
type: screenOrientationType,
angle: window.screen.orientation.angle,
},
deviceScaleFactor: window.devicePixelRatio,
};
}
/**
* The screenshot dimensions are sized to `window.outerHeight` / `window.outerWidth`,
* however the bounding boxes of the elements are relative to `window.innerHeight` / `window.innerWidth`.
*/
function getScreenshotAreaSize() {
return {
width: window.innerWidth,
height: window.innerHeight,
};
}
function waitForDoubleRaf() {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
}
/* c8 ignore stop */
class FullPageScreenshot extends BaseGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
/**
* @param {LH.Gatherer.Context} context
* @param {{height: number, width: number, mobile: boolean}} deviceMetrics
*/
async _resizeViewport(context, deviceMetrics) {
const session = context.driver.defaultSession;
const metrics = await session.sendCommand('Page.getLayoutMetrics');
// Height should be as tall as the content.
// Scale the emulated height to reach the content height.
const fullHeight = Math.round(
deviceMetrics.height *
metrics.cssContentSize.height /
metrics.cssLayoutViewport.clientHeight
);
const height = Math.min(fullHeight, MAX_WEBP_SIZE);
// Setup network monitor before we change the viewport.
const networkMonitor = context.driver.networkMonitor;
const waitForNetworkIdleResult = waitForNetworkIdle(session, networkMonitor, {
pretendDCLAlreadyFired: true,
networkQuietThresholdMs: 1000,
busyEvent: 'network-critical-busy',
idleEvent: 'network-critical-idle',
isIdle: recorder => recorder.isCriticalIdle(),
});
await session.sendCommand('Emulation.setDeviceMetricsOverride', {
mobile: deviceMetrics.mobile,
deviceScaleFactor: 1,
height,
width: 0, // Leave width unchanged
});
// Now that the viewport is taller, give the page some time to fetch new resources that
// are now in view.
await Promise.race([
new Promise(resolve => setTimeout(resolve, 1000 * 5)),
waitForNetworkIdleResult.promise,
]);
waitForNetworkIdleResult.cancel();
// Now that new resources are (probably) fetched, wait long enough for a layout.
await context.driver.executionContext.evaluate(waitForDoubleRaf, {args: []});
}
/**
* @param {LH.Gatherer.Context} context
* @return {Promise<LH.Result.FullPageScreenshot['screenshot']>}
*/
async _takeScreenshot(context) {
const result = await context.driver.defaultSession.sendCommand('Page.captureScreenshot', {
format: 'webp',
quality: FULL_PAGE_SCREENSHOT_QUALITY,
});
const data = 'data:image/webp;base64,' + result.data;
const screenshotAreaSize =
await context.driver.executionContext.evaluate(getScreenshotAreaSize, {
args: [],
useIsolation: true,
});
return {
data,
width: screenshotAreaSize.width,
height: screenshotAreaSize.height,
};
}
/**
* Gatherers can collect details about DOM nodes, including their position on the page.
* Layout shifts occuring after a gatherer runs can cause these positions to be incorrect,
* resulting in a poor experience for element screenshots.
* `getNodeDetails` maintains a collection of DOM objects in the page, which we can iterate
* to re-collect the bounding client rectangle.
* @see pageFunctions.getNodeDetails
* @param {LH.Gatherer.Context} context
* @return {Promise<LH.Result.FullPageScreenshot['nodes']>}
*/
async _resolveNodes(context) {
function resolveNodes() {
/** @type {LH.Result.FullPageScreenshot['nodes']} */
const nodes = {};
if (!window.__lighthouseNodesDontTouchOrAllVarianceGoesAway) return nodes;
const lhIdToElements = window.__lighthouseNodesDontTouchOrAllVarianceGoesAway;
for (const [node, id] of lhIdToElements.entries()) {
// @ts-expect-error - getBoundingClientRect put into scope via stringification
const rect = getBoundingClientRect(node);
nodes[id] = rect;
}
return nodes;
}
/**
* @param {{useIsolation: boolean}} _
*/
function resolveNodesInPage({useIsolation}) {
return context.driver.executionContext.evaluate(resolveNodes, {
args: [],
useIsolation,
deps: [pageFunctions.getBoundingClientRect],
});
}
// Collect nodes with the page context (`useIsolation: false`) and with our own, reused
// context (`useIsolation: true`). Gatherers use both modes when collecting node details,
// so we must do the same here too.
const pageContextResult = await resolveNodesInPage({useIsolation: false});
const isolatedContextResult = await resolveNodesInPage({useIsolation: true});
return {...pageContextResult, ...isolatedContextResult};
}
/**
* @param {LH.Gatherer.Context} context
* @return {Promise<LH.Artifacts['FullPageScreenshot']>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;
const executionContext = context.driver.executionContext;
const settings = context.settings;
const lighthouseControlsEmulation = !settings.screenEmulation.disabled;
// Make a copy so we don't modify the config settings.
/** @type {{width: number, height: number, deviceScaleFactor: number, mobile: boolean}} */
const deviceMetrics = {...settings.screenEmulation};
try {
if (!settings.usePassiveGathering) {
// In case some other program is controlling emulation, remember what the device looks like now and reset after gatherer is done.
// If we're gathering with mobile screenEmulation on (overlay scrollbars, etc), continue to use that for this screenshot.
if (!lighthouseControlsEmulation) {
const observedDeviceMetrics = await executionContext.evaluate(getObservedDeviceMetrics, {
args: [],
useIsolation: true,
deps: [kebabCaseToCamelCase],
});
deviceMetrics.height = observedDeviceMetrics.height;
deviceMetrics.width = observedDeviceMetrics.width;
deviceMetrics.deviceScaleFactor = observedDeviceMetrics.deviceScaleFactor;
// If screen emulation is disabled, use formFactor to determine if we are on mobile.
deviceMetrics.mobile = settings.formFactor === 'mobile';
}
await this._resizeViewport(context, deviceMetrics);
}
// Issue both commands at once, to reduce the chance that the page changes between capturing
// a screenshot and resolving the nodes. https://github.com/GoogleChrome/lighthouse/pull/14763
const [screenshot, nodes] =
await Promise.all([this._takeScreenshot(context), this._resolveNodes(context)]);
return {
screenshot,
nodes,
};
} finally {
if (!settings.usePassiveGathering) {
// Revert resized page.
if (lighthouseControlsEmulation) {
await emulation.emulate(session, settings);
} else {
// Best effort to reset emulation to what it was.
// https://github.com/GoogleChrome/lighthouse/pull/10716#discussion_r428970681
// TODO: seems like this would be brittle. Should at least work for devtools, but what
// about scripted puppeteer usages? Better to introduce a "setEmulation" callback
// in the LH runner api, which for ex. puppeteer consumers would setup puppeteer emulation,
// and then just call that to reset?
// https://github.com/GoogleChrome/lighthouse/issues/11122
await session.sendCommand('Emulation.setDeviceMetricsOverride', {
mobile: deviceMetrics.mobile,
deviceScaleFactor: deviceMetrics.deviceScaleFactor,
height: deviceMetrics.height,
width: 0, // Leave width unchanged
});
}
}
}
}
}
export default FullPageScreenshot;