@percy/core 1.11.0 → 1.13.0
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/dist/api.js +60 -45
- package/dist/browser.js +82 -68
- package/dist/config.js +16 -9
- package/dist/discovery.js +65 -60
- package/dist/install.js +29 -27
- package/dist/network.js +81 -83
- package/dist/page.js +53 -54
- package/dist/percy.js +85 -85
- package/dist/queue.js +103 -146
- package/dist/server.js +51 -88
- package/dist/session.js +10 -16
- package/dist/snapshot.js +105 -92
- package/dist/utils.js +60 -58
- package/package.json +6 -6
package/dist/network.js
CHANGED
|
@@ -3,11 +3,11 @@ import logger from '@percy/logger';
|
|
|
3
3
|
import { request as makeRequest } from '@percy/client/utils';
|
|
4
4
|
import { normalizeURL, hostnameMatches, createResource, waitFor } from './utils.js';
|
|
5
5
|
const MAX_RESOURCE_SIZE = 15 * 1024 ** 2; // 15MB
|
|
6
|
-
|
|
7
6
|
const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
|
|
8
|
-
const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
|
|
9
|
-
// for a given page using various devtools protocol events and commands.
|
|
7
|
+
const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
|
|
10
8
|
|
|
9
|
+
// The Interceptor class creates common handlers for dealing with intercepting asset requests
|
|
10
|
+
// for a given page using various devtools protocol events and commands.
|
|
11
11
|
export class Network {
|
|
12
12
|
static TIMEOUT = 30000;
|
|
13
13
|
log = logger('core:discovery');
|
|
@@ -15,18 +15,17 @@ export class Network {
|
|
|
15
15
|
#requests = new Map();
|
|
16
16
|
#intercepts = new Map();
|
|
17
17
|
#authentications = new Set();
|
|
18
|
-
|
|
19
18
|
constructor(page, options) {
|
|
20
19
|
this.page = page;
|
|
21
20
|
this.timeout = options.networkIdleTimeout ?? 100;
|
|
22
21
|
this.authorization = options.authorization;
|
|
23
22
|
this.requestHeaders = options.requestHeaders ?? {};
|
|
24
|
-
this.userAgent = options.userAgent ??
|
|
23
|
+
this.userAgent = options.userAgent ??
|
|
24
|
+
// by default, emulate a non-headless browser
|
|
25
25
|
page.session.browser.version.userAgent.replace('Headless', '');
|
|
26
26
|
this.intercept = options.intercept;
|
|
27
27
|
this.meta = options.meta;
|
|
28
28
|
}
|
|
29
|
-
|
|
30
29
|
watch(session) {
|
|
31
30
|
session.on('Network.requestWillBeSent', this._handleRequestWillBeSent);
|
|
32
31
|
session.on('Network.responseReceived', this._handleResponseReceived.bind(this, session));
|
|
@@ -42,7 +41,6 @@ export class Network {
|
|
|
42
41
|
}), session.send('Network.setExtraHTTPHeaders', {
|
|
43
42
|
headers: this.requestHeaders
|
|
44
43
|
})];
|
|
45
|
-
|
|
46
44
|
if (this.intercept && session.isDocument) {
|
|
47
45
|
session.on('Fetch.requestPaused', this._handleRequestPaused.bind(this, session));
|
|
48
46
|
session.on('Fetch.authRequired', this._handleAuthRequired.bind(this, session));
|
|
@@ -53,11 +51,10 @@ export class Network {
|
|
|
53
51
|
}]
|
|
54
52
|
}));
|
|
55
53
|
}
|
|
56
|
-
|
|
57
54
|
return Promise.all(commands);
|
|
58
|
-
}
|
|
59
|
-
|
|
55
|
+
}
|
|
60
56
|
|
|
57
|
+
// Resolves after the timeout when there are no more in-flight requests.
|
|
61
58
|
async idle(filter = () => true, timeout = this.timeout) {
|
|
62
59
|
let requests = [];
|
|
63
60
|
this.log.debug(`Wait for ${timeout}ms idle`, this.meta);
|
|
@@ -65,44 +62,44 @@ export class Network {
|
|
|
65
62
|
if (this.page.session.closedReason) {
|
|
66
63
|
throw new Error(`Network error: ${this.page.session.closedReason}`);
|
|
67
64
|
}
|
|
68
|
-
|
|
69
65
|
requests = Array.from(this.#requests.values()).filter(filter);
|
|
70
66
|
return requests.length === 0;
|
|
71
67
|
}, {
|
|
72
68
|
timeout: Network.TIMEOUT,
|
|
73
69
|
idle: timeout
|
|
74
70
|
}).catch(error => {
|
|
75
|
-
// throw a better timeout error
|
|
76
71
|
if (error.message.startsWith('Timeout')) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (this.log.shouldLog('debug')) {
|
|
80
|
-
msg += `\n\n ${['Active requests:', ...requests.map(r => r.url)].join('\n - ')}\n`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
throw new Error(msg);
|
|
72
|
+
this._throwTimeoutError('Timed out waiting for network requests to idle.', filter);
|
|
84
73
|
} else {
|
|
85
74
|
throw error;
|
|
86
75
|
}
|
|
87
76
|
});
|
|
88
|
-
}
|
|
77
|
+
}
|
|
89
78
|
|
|
79
|
+
// Throw a better network timeout error
|
|
80
|
+
_throwTimeoutError(msg, filter = () => true) {
|
|
81
|
+
if (this.log.shouldLog('debug')) {
|
|
82
|
+
let reqs = Array.from(this.#requests.values()).filter(filter).map(r => r.url);
|
|
83
|
+
msg += `\n\n ${['Active requests:', ...reqs].join('\n - ')}\n`;
|
|
84
|
+
}
|
|
85
|
+
throw new Error(msg);
|
|
86
|
+
}
|
|
90
87
|
|
|
88
|
+
// Called when a request should be removed from various trackers
|
|
91
89
|
_forgetRequest({
|
|
92
90
|
requestId,
|
|
93
91
|
interceptId
|
|
94
92
|
}, keepPending) {
|
|
95
93
|
this.#requests.delete(requestId);
|
|
96
94
|
this.#authentications.delete(interceptId);
|
|
97
|
-
|
|
98
95
|
if (!keepPending) {
|
|
99
96
|
this.#pending.delete(requestId);
|
|
100
97
|
this.#intercepts.delete(requestId);
|
|
101
98
|
}
|
|
102
|
-
}
|
|
103
|
-
// provided authorization credentials.
|
|
104
|
-
|
|
99
|
+
}
|
|
105
100
|
|
|
101
|
+
// Called when a request requires authentication. Responds to the auth request with any
|
|
102
|
+
// provided authorization credentials.
|
|
106
103
|
_handleAuthRequired = async (session, event) => {
|
|
107
104
|
let {
|
|
108
105
|
username,
|
|
@@ -112,14 +109,12 @@ export class Network {
|
|
|
112
109
|
requestId
|
|
113
110
|
} = event;
|
|
114
111
|
let response = 'Default';
|
|
115
|
-
|
|
116
112
|
if (this.#authentications.has(requestId)) {
|
|
117
113
|
response = 'CancelAuth';
|
|
118
114
|
} else if (username || password) {
|
|
119
115
|
response = 'ProvideCredentials';
|
|
120
116
|
this.#authentications.add(requestId);
|
|
121
117
|
}
|
|
122
|
-
|
|
123
118
|
await session.send('Fetch.continueWithAuth', {
|
|
124
119
|
requestId: event.requestId,
|
|
125
120
|
authChallengeResponse: {
|
|
@@ -128,9 +123,10 @@ export class Network {
|
|
|
128
123
|
password
|
|
129
124
|
}
|
|
130
125
|
});
|
|
131
|
-
};
|
|
132
|
-
// aborted. If the request is already pending, handle it; otherwise set it to be intercepted.
|
|
126
|
+
};
|
|
133
127
|
|
|
128
|
+
// Called when a request is made. The request is paused until it is fulfilled, continued, or
|
|
129
|
+
// aborted. If the request is already pending, handle it; otherwise set it to be intercepted.
|
|
134
130
|
_handleRequestPaused = async (session, event) => {
|
|
135
131
|
let {
|
|
136
132
|
networkId: requestId,
|
|
@@ -138,34 +134,37 @@ export class Network {
|
|
|
138
134
|
resourceType
|
|
139
135
|
} = event;
|
|
140
136
|
let pending = this.#pending.get(requestId);
|
|
141
|
-
this.#pending.delete(requestId);
|
|
137
|
+
this.#pending.delete(requestId);
|
|
142
138
|
|
|
139
|
+
// guard against redirects with the same requestId
|
|
143
140
|
if ((pending === null || pending === void 0 ? void 0 : pending.request.url) === event.request.url && pending.request.method === event.request.method) {
|
|
144
|
-
await this._handleRequest(session, {
|
|
141
|
+
await this._handleRequest(session, {
|
|
142
|
+
...pending,
|
|
145
143
|
resourceType,
|
|
146
144
|
interceptId
|
|
147
145
|
});
|
|
148
146
|
} else {
|
|
149
147
|
// track the session that intercepted the request
|
|
150
|
-
this.#intercepts.set(requestId, {
|
|
148
|
+
this.#intercepts.set(requestId, {
|
|
149
|
+
...event,
|
|
151
150
|
session
|
|
152
151
|
});
|
|
153
152
|
}
|
|
154
|
-
};
|
|
155
|
-
// otherwise set it to be pending until it is paused.
|
|
153
|
+
};
|
|
156
154
|
|
|
155
|
+
// Called when a request will be sent. If the request has already been intercepted, handle it;
|
|
156
|
+
// otherwise set it to be pending until it is paused.
|
|
157
157
|
_handleRequestWillBeSent = async event => {
|
|
158
158
|
let {
|
|
159
159
|
requestId,
|
|
160
160
|
request
|
|
161
|
-
} = event;
|
|
161
|
+
} = event;
|
|
162
162
|
|
|
163
|
+
// do not handle data urls
|
|
163
164
|
if (request.url.startsWith('data:')) return;
|
|
164
|
-
|
|
165
165
|
if (this.intercept) {
|
|
166
166
|
let intercept = this.#intercepts.get(requestId);
|
|
167
167
|
this.#pending.set(requestId, event);
|
|
168
|
-
|
|
169
168
|
if (intercept) {
|
|
170
169
|
// handle the request with the session that intercepted it
|
|
171
170
|
let {
|
|
@@ -173,17 +172,19 @@ export class Network {
|
|
|
173
172
|
requestId: interceptId,
|
|
174
173
|
resourceType
|
|
175
174
|
} = intercept;
|
|
176
|
-
await this._handleRequest(session, {
|
|
175
|
+
await this._handleRequest(session, {
|
|
176
|
+
...event,
|
|
177
177
|
resourceType,
|
|
178
178
|
interceptId
|
|
179
179
|
});
|
|
180
180
|
this.#intercepts.delete(requestId);
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Called when a pending request is paused. Handles associating redirected requests with
|
|
184
186
|
// responses and calls this.onrequest with request info and callbacks to continue, respond,
|
|
185
187
|
// or abort a request. One of the callbacks is required to be called and only one.
|
|
186
|
-
|
|
187
188
|
_handleRequest = async (session, event) => {
|
|
188
189
|
let {
|
|
189
190
|
request,
|
|
@@ -191,24 +192,25 @@ export class Network {
|
|
|
191
192
|
interceptId,
|
|
192
193
|
resourceType
|
|
193
194
|
} = event;
|
|
194
|
-
let redirectChain = [];
|
|
195
|
+
let redirectChain = [];
|
|
195
196
|
|
|
197
|
+
// if handling a redirected request, associate the response and add to its redirect chain
|
|
196
198
|
if (event.redirectResponse && this.#requests.has(requestId)) {
|
|
197
199
|
let req = this.#requests.get(requestId);
|
|
198
|
-
redirectChain = [...req.redirectChain, req];
|
|
199
|
-
|
|
200
|
+
redirectChain = [...req.redirectChain, req];
|
|
201
|
+
// clean up interim requests
|
|
200
202
|
this._forgetRequest(req, true);
|
|
201
203
|
}
|
|
202
|
-
|
|
203
204
|
request.type = resourceType;
|
|
204
205
|
request.requestId = requestId;
|
|
205
206
|
request.interceptId = interceptId;
|
|
206
207
|
request.redirectChain = redirectChain;
|
|
207
208
|
this.#requests.set(requestId, request);
|
|
208
209
|
await sendResponseResource(this, request, session);
|
|
209
|
-
};
|
|
210
|
-
// the request data and adds a buffer method to fetch the response body when needed.
|
|
210
|
+
};
|
|
211
211
|
|
|
212
|
+
// Called when a response has been received for a specific request. Associates the response with
|
|
213
|
+
// the request data and adds a buffer method to fetch the response body when needed.
|
|
212
214
|
_handleResponseReceived = (session, event) => {
|
|
213
215
|
let {
|
|
214
216
|
requestId,
|
|
@@ -216,59 +218,58 @@ export class Network {
|
|
|
216
218
|
} = event;
|
|
217
219
|
let request = this.#requests.get(requestId);
|
|
218
220
|
/* istanbul ignore if: race condition paranioa */
|
|
219
|
-
|
|
220
221
|
if (!request) return;
|
|
221
222
|
request.response = response;
|
|
222
|
-
|
|
223
223
|
request.response.buffer = async () => {
|
|
224
224
|
let result = await session.send('Network.getResponseBody', {
|
|
225
225
|
requestId
|
|
226
226
|
});
|
|
227
227
|
return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8');
|
|
228
228
|
};
|
|
229
|
-
};
|
|
230
|
-
// they never finish loading, so we untrack them to signal idle after the first event.
|
|
229
|
+
};
|
|
231
230
|
|
|
231
|
+
// Called when a request streams events. These types of requests break asset discovery because
|
|
232
|
+
// they never finish loading, so we untrack them to signal idle after the first event.
|
|
232
233
|
_handleEventSourceMessageReceived = event => {
|
|
233
234
|
let request = this.#requests.get(event.requestId);
|
|
234
235
|
/* istanbul ignore else: race condition paranioa */
|
|
235
|
-
|
|
236
236
|
if (request) this._forgetRequest(request);
|
|
237
|
-
};
|
|
238
|
-
// callback. The request should have an associated response and be finished with any redirects.
|
|
237
|
+
};
|
|
239
238
|
|
|
239
|
+
// Called when a request has finished loading which triggers the this.onrequestfinished
|
|
240
|
+
// callback. The request should have an associated response and be finished with any redirects.
|
|
240
241
|
_handleLoadingFinished = async event => {
|
|
241
242
|
let request = this.#requests.get(event.requestId);
|
|
242
243
|
/* istanbul ignore if: race condition paranioa */
|
|
243
|
-
|
|
244
244
|
if (!request) return;
|
|
245
245
|
await saveResponseResource(this, request);
|
|
246
|
-
|
|
247
246
|
this._forgetRequest(request);
|
|
248
|
-
};
|
|
247
|
+
};
|
|
249
248
|
|
|
249
|
+
// Called when a request has failed loading and triggers the this.onrequestfailed callback.
|
|
250
250
|
_handleLoadingFailed = event => {
|
|
251
251
|
let request = this.#requests.get(event.requestId);
|
|
252
252
|
/* istanbul ignore if: race condition paranioa */
|
|
253
|
+
if (!request) return;
|
|
253
254
|
|
|
254
|
-
|
|
255
|
-
|
|
255
|
+
// do not log generic messages since the real error was likely logged elsewhere
|
|
256
256
|
if (event.errorText !== 'net::ERR_FAILED') {
|
|
257
257
|
let message = `Request failed for ${request.url}: ${event.errorText}`;
|
|
258
|
-
this.log.debug(message, {
|
|
258
|
+
this.log.debug(message, {
|
|
259
|
+
...this.meta,
|
|
259
260
|
url: request.url
|
|
260
261
|
});
|
|
261
262
|
}
|
|
262
|
-
|
|
263
263
|
this._forgetRequest(request);
|
|
264
264
|
};
|
|
265
|
-
}
|
|
265
|
+
}
|
|
266
266
|
|
|
267
|
+
// Returns the normalized origin URL of a request
|
|
267
268
|
function originURL(request) {
|
|
268
269
|
return normalizeURL((request.redirectChain[0] || request).url);
|
|
269
|
-
}
|
|
270
|
-
|
|
270
|
+
}
|
|
271
271
|
|
|
272
|
+
// Send a response for a given request, responding with cached resources when able
|
|
272
273
|
async function sendResponseResource(network, request, session) {
|
|
273
274
|
let {
|
|
274
275
|
disallowedHostnames,
|
|
@@ -276,14 +277,13 @@ async function sendResponseResource(network, request, session) {
|
|
|
276
277
|
} = network.intercept;
|
|
277
278
|
let log = network.log;
|
|
278
279
|
let url = originURL(request);
|
|
279
|
-
let meta = {
|
|
280
|
+
let meta = {
|
|
281
|
+
...network.meta,
|
|
280
282
|
url
|
|
281
283
|
};
|
|
282
|
-
|
|
283
284
|
try {
|
|
284
285
|
let resource = network.intercept.getResource(url);
|
|
285
286
|
network.log.debug(`Handling request: ${url}`, meta);
|
|
286
|
-
|
|
287
287
|
if (!(resource !== null && resource !== void 0 && resource.root) && hostnameMatches(disallowedHostnames, url)) {
|
|
288
288
|
log.debug('- Skipping disallowed hostname', meta);
|
|
289
289
|
await session.send('Fetch.failRequest', {
|
|
@@ -307,24 +307,25 @@ async function sendResponseResource(network, request, session) {
|
|
|
307
307
|
});
|
|
308
308
|
}
|
|
309
309
|
} catch (error) {
|
|
310
|
+
/* istanbul ignore next: too hard to test (create race condition) */
|
|
311
|
+
if (session.closing && error.message.includes('close')) return;
|
|
310
312
|
log.debug(`Encountered an error handling request: ${url}`, meta);
|
|
311
313
|
log.debug(error);
|
|
312
|
-
/* istanbul ignore next: catch race condition */
|
|
313
314
|
|
|
315
|
+
/* istanbul ignore next: catch race condition */
|
|
314
316
|
await session.send('Fetch.failRequest', {
|
|
315
317
|
requestId: request.interceptId,
|
|
316
318
|
errorReason: 'Failed'
|
|
317
319
|
}).catch(e => log.debug(e, meta));
|
|
318
320
|
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
+
}
|
|
321
322
|
|
|
323
|
+
// Make a new request with Node based on a network request
|
|
322
324
|
function makeDirectRequest(network, request) {
|
|
323
325
|
var _network$authorizatio;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
let headers = {
|
|
327
|
+
...request.headers
|
|
326
328
|
};
|
|
327
|
-
|
|
328
329
|
if ((_network$authorizatio = network.authorization) !== null && _network$authorizatio !== void 0 && _network$authorizatio.username) {
|
|
329
330
|
// include basic authorization username and password
|
|
330
331
|
let {
|
|
@@ -334,14 +335,13 @@ function makeDirectRequest(network, request) {
|
|
|
334
335
|
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
|
|
335
336
|
headers.Authorization = `Basic ${token}`;
|
|
336
337
|
}
|
|
337
|
-
|
|
338
338
|
return makeRequest(request.url, {
|
|
339
339
|
buffer: true,
|
|
340
340
|
headers
|
|
341
341
|
});
|
|
342
|
-
}
|
|
343
|
-
|
|
342
|
+
}
|
|
344
343
|
|
|
344
|
+
// Save a resource from a request, skipping it if specific paramters are not met
|
|
345
345
|
async function saveResponseResource(network, request) {
|
|
346
346
|
let {
|
|
347
347
|
disableCache,
|
|
@@ -351,18 +351,18 @@ async function saveResponseResource(network, request) {
|
|
|
351
351
|
let log = network.log;
|
|
352
352
|
let url = originURL(request);
|
|
353
353
|
let response = request.response;
|
|
354
|
-
let meta = {
|
|
354
|
+
let meta = {
|
|
355
|
+
...network.meta,
|
|
355
356
|
url
|
|
356
357
|
};
|
|
357
358
|
let resource = network.intercept.getResource(url);
|
|
358
|
-
|
|
359
359
|
if (!resource || !resource.root && disableCache) {
|
|
360
360
|
try {
|
|
361
361
|
log.debug(`Processing resource: ${url}`, meta);
|
|
362
362
|
let shouldCapture = response && hostnameMatches(allowedHostnames, url);
|
|
363
363
|
let body = shouldCapture && (await response.buffer());
|
|
364
|
-
/* istanbul ignore if: first check is a sanity check */
|
|
365
364
|
|
|
365
|
+
/* istanbul ignore if: first check is a sanity check */
|
|
366
366
|
if (!response) {
|
|
367
367
|
return log.debug('- Skipping no response', meta);
|
|
368
368
|
} else if (!shouldCapture) {
|
|
@@ -376,15 +376,15 @@ async function saveResponseResource(network, request) {
|
|
|
376
376
|
} else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) {
|
|
377
377
|
return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta);
|
|
378
378
|
}
|
|
379
|
+
let mimeType =
|
|
380
|
+
// ensure the mimetype is correct for text/plain responses
|
|
381
|
+
response.mimeType === 'text/plain' && mime.lookup(response.url) || response.mimeType;
|
|
379
382
|
|
|
380
|
-
|
|
381
|
-
response.mimeType === 'text/plain' && mime.lookup(response.url) || response.mimeType; // font responses from the browser may not be properly encoded, so request them directly
|
|
382
|
-
|
|
383
|
+
// font responses from the browser may not be properly encoded, so request them directly
|
|
383
384
|
if (mimeType !== null && mimeType !== void 0 && mimeType.includes('font')) {
|
|
384
385
|
log.debug('- Requesting asset directly');
|
|
385
386
|
body = await makeDirectRequest(network, request);
|
|
386
387
|
}
|
|
387
|
-
|
|
388
388
|
resource = createResource(url, body, mimeType, {
|
|
389
389
|
status: response.status,
|
|
390
390
|
// 'Network.responseReceived' returns headers split by newlines, however
|
|
@@ -400,10 +400,8 @@ async function saveResponseResource(network, request) {
|
|
|
400
400
|
log.debug(error);
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
-
|
|
404
403
|
if (resource) {
|
|
405
404
|
network.intercept.saveResource(resource);
|
|
406
405
|
}
|
|
407
406
|
}
|
|
408
|
-
|
|
409
407
|
export default Network;
|
package/dist/page.js
CHANGED
|
@@ -6,7 +6,6 @@ import { hostname, waitFor, waitForTimeout as sleep, serializeFunction } from '.
|
|
|
6
6
|
export class Page {
|
|
7
7
|
static TIMEOUT = 30000;
|
|
8
8
|
log = logger('core:page');
|
|
9
|
-
|
|
10
9
|
constructor(session, options) {
|
|
11
10
|
this.session = session;
|
|
12
11
|
this.browser = session.browser;
|
|
@@ -18,15 +17,15 @@ export class Page {
|
|
|
18
17
|
session.on('Runtime.executionContextsCleared', this._handleExecutionContextsCleared);
|
|
19
18
|
session.send('Runtime.enable').catch(session._handleClosedError);
|
|
20
19
|
this.log.debug('Page created');
|
|
21
|
-
}
|
|
22
|
-
|
|
20
|
+
}
|
|
23
21
|
|
|
22
|
+
// Close the page
|
|
24
23
|
async close() {
|
|
25
24
|
await this.session.close();
|
|
26
25
|
this.log.debug('Page closed', this.meta);
|
|
27
|
-
}
|
|
28
|
-
|
|
26
|
+
}
|
|
29
27
|
|
|
28
|
+
// Resize the page to the specified width and height
|
|
30
29
|
async resize({
|
|
31
30
|
width,
|
|
32
31
|
height,
|
|
@@ -40,45 +39,42 @@ export class Page {
|
|
|
40
39
|
height,
|
|
41
40
|
width
|
|
42
41
|
});
|
|
43
|
-
}
|
|
44
|
-
|
|
42
|
+
}
|
|
45
43
|
|
|
44
|
+
// Go to a URL and wait for navigation to occur
|
|
46
45
|
async goto(url, {
|
|
47
46
|
waitUntil = 'load'
|
|
48
47
|
} = {}) {
|
|
49
48
|
this.log.debug(`Navigate to: ${url}`, this.meta);
|
|
50
|
-
|
|
51
49
|
let navigate = async () => {
|
|
52
50
|
// set cookies before navigation so we can default the domain to this hostname
|
|
53
51
|
if (this.session.browser.cookies.length) {
|
|
54
52
|
let defaultDomain = hostname(url);
|
|
55
53
|
await this.session.send('Network.setCookies', {
|
|
56
54
|
// spread is used to make a shallow copy of the cookie
|
|
57
|
-
cookies: this.session.browser.cookies.map(({
|
|
55
|
+
cookies: this.session.browser.cookies.map(({
|
|
56
|
+
...cookie
|
|
58
57
|
}) => {
|
|
59
58
|
if (!cookie.url) cookie.domain || (cookie.domain = defaultDomain);
|
|
60
59
|
return cookie;
|
|
61
60
|
})
|
|
62
61
|
});
|
|
63
|
-
}
|
|
64
|
-
|
|
62
|
+
}
|
|
65
63
|
|
|
64
|
+
// handle navigation errors
|
|
66
65
|
let res = await this.session.send('Page.navigate', {
|
|
67
66
|
url
|
|
68
67
|
});
|
|
69
68
|
if (res.errorText) throw new Error(res.errorText);
|
|
70
69
|
};
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
let handlers = [
|
|
71
|
+
// wait until navigation and the correct lifecycle
|
|
73
72
|
['Page.frameNavigated', e => this.session.targetId === e.frame.id], ['Page.lifecycleEvent', e => this.session.targetId === e.frameId && e.name === waitUntil]].map(([name, cond]) => {
|
|
74
73
|
let handler = e => cond(e) && (handler.finished = true) && handler.off();
|
|
75
|
-
|
|
76
74
|
handler.off = () => this.session.off(name, handler);
|
|
77
|
-
|
|
78
75
|
this.session.on(name, handler);
|
|
79
76
|
return handler;
|
|
80
77
|
});
|
|
81
|
-
|
|
82
78
|
try {
|
|
83
79
|
// trigger navigation and poll for handlers to have finished
|
|
84
80
|
await Promise.all([navigate(), waitFor(() => {
|
|
@@ -86,18 +82,23 @@ export class Page {
|
|
|
86
82
|
return handlers.every(handler => handler.finished);
|
|
87
83
|
}, Page.TIMEOUT)]);
|
|
88
84
|
} catch (error) {
|
|
89
|
-
// remove
|
|
85
|
+
// remove any unused handlers
|
|
90
86
|
for (let handler of handlers) handler.off();
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
// assign context to unknown errors
|
|
89
|
+
if (!error.message.startsWith('Timeout')) {
|
|
90
|
+
throw Object.assign(error, {
|
|
91
|
+
message: `Navigation failed: ${error.message}`
|
|
92
|
+
});
|
|
93
|
+
}
|
|
96
94
|
|
|
95
|
+
// throw a network error to show active requests
|
|
96
|
+
this.network._throwTimeoutError(`Navigation failed: Timed out waiting for the page ${waitUntil} event`);
|
|
97
|
+
}
|
|
97
98
|
this.log.debug('Page navigated', this.meta);
|
|
98
|
-
}
|
|
99
|
-
|
|
99
|
+
}
|
|
100
100
|
|
|
101
|
+
// Evaluate JS functions within the page's execution context
|
|
101
102
|
async eval(fn, ...args) {
|
|
102
103
|
let {
|
|
103
104
|
result,
|
|
@@ -112,29 +113,27 @@ export class Page {
|
|
|
112
113
|
awaitPromise: true,
|
|
113
114
|
userGesture: true
|
|
114
115
|
});
|
|
115
|
-
|
|
116
116
|
if (exceptionDetails) {
|
|
117
117
|
throw exceptionDetails.exception.description;
|
|
118
118
|
} else {
|
|
119
119
|
return result.value;
|
|
120
120
|
}
|
|
121
|
-
}
|
|
122
|
-
|
|
121
|
+
}
|
|
123
122
|
|
|
123
|
+
// Evaluate one or more scripts in succession
|
|
124
124
|
async evaluate(scripts) {
|
|
125
125
|
var _scripts;
|
|
126
|
-
|
|
127
126
|
if (!((_scripts = scripts && (scripts = [].concat(scripts))) !== null && _scripts !== void 0 && _scripts.length)) return;
|
|
128
|
-
this.log.debug('Evaluate JavaScript', {
|
|
127
|
+
this.log.debug('Evaluate JavaScript', {
|
|
128
|
+
...this.meta,
|
|
129
129
|
scripts
|
|
130
130
|
});
|
|
131
|
-
|
|
132
131
|
for (let script of scripts) await this.eval(script);
|
|
133
|
-
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Takes a snapshot after waiting for any timeout, waiting for any selector, executing any
|
|
134
135
|
// scripts, and waiting for the network idle. Returns all other provided snapshot options along
|
|
135
136
|
// with the captured URL and DOM snapshot.
|
|
136
|
-
|
|
137
|
-
|
|
138
137
|
async snapshot({
|
|
139
138
|
waitForTimeout,
|
|
140
139
|
waitForSelector,
|
|
@@ -146,42 +145,42 @@ export class Page {
|
|
|
146
145
|
width,
|
|
147
146
|
enableJavaScript
|
|
148
147
|
} = snapshot;
|
|
149
|
-
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);
|
|
148
|
+
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);
|
|
150
149
|
|
|
150
|
+
// wait for any specified timeout
|
|
151
151
|
if (waitForTimeout) {
|
|
152
152
|
this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta);
|
|
153
153
|
await sleep(waitForTimeout);
|
|
154
|
-
}
|
|
155
|
-
|
|
154
|
+
}
|
|
156
155
|
|
|
156
|
+
// wait for any specified selector
|
|
157
157
|
if (waitForSelector) {
|
|
158
158
|
this.log.debug(`Wait for selector: ${waitForSelector}`, this.meta);
|
|
159
159
|
await this.eval(`await waitForSelector(${JSON.stringify(waitForSelector)}, ${Page.TIMEOUT})`);
|
|
160
|
-
}
|
|
161
|
-
|
|
160
|
+
}
|
|
162
161
|
|
|
162
|
+
// execute any javascript
|
|
163
163
|
if (execute) {
|
|
164
164
|
let execBefore = typeof execute === 'object' && !Array.isArray(execute);
|
|
165
165
|
await this.evaluate(execBefore ? execute.beforeSnapshot : execute);
|
|
166
|
-
}
|
|
166
|
+
}
|
|
167
167
|
|
|
168
|
+
// wait for any final network activity before capturing the dom snapshot
|
|
169
|
+
await this.network.idle();
|
|
168
170
|
|
|
169
|
-
|
|
171
|
+
// inject @percy/dom for serialization by evaluating the file contents which adds a global
|
|
170
172
|
// PercyDOM object that we can later check against
|
|
171
|
-
|
|
172
173
|
/* istanbul ignore next: no instrumenting injected code */
|
|
173
|
-
|
|
174
174
|
if (await this.eval(() => !window.PercyDOM)) {
|
|
175
175
|
this.log.debug('Inject @percy/dom', this.meta);
|
|
176
176
|
let script = await fs.promises.readFile(PERCY_DOM, 'utf-8');
|
|
177
|
-
await this.eval(new Function(script));
|
|
178
|
-
|
|
179
|
-
} // serialize and capture a DOM snapshot
|
|
180
|
-
|
|
177
|
+
await this.eval(new Function(script)); /* eslint-disable-line no-new-func */
|
|
178
|
+
}
|
|
181
179
|
|
|
180
|
+
// serialize and capture a DOM snapshot
|
|
182
181
|
this.log.debug('Serialize DOM', this.meta);
|
|
183
|
-
/* istanbul ignore next: no instrumenting injected code */
|
|
184
182
|
|
|
183
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
185
184
|
let capture = await this.eval((_, options) => ({
|
|
186
185
|
/* eslint-disable-next-line no-undef */
|
|
187
186
|
domSnapshot: PercyDOM.serialize(options),
|
|
@@ -189,19 +188,18 @@ export class Page {
|
|
|
189
188
|
}), {
|
|
190
189
|
enableJavaScript
|
|
191
190
|
});
|
|
192
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
...snapshot,
|
|
193
193
|
...capture
|
|
194
194
|
};
|
|
195
|
-
}
|
|
196
|
-
|
|
195
|
+
}
|
|
197
196
|
|
|
197
|
+
// Initialize newly attached pages and iframes with page options
|
|
198
198
|
_handleAttachedToTarget = event => {
|
|
199
199
|
let session = !event ? this.session : this.session.children.get(event.sessionId);
|
|
200
200
|
/* istanbul ignore if: sanity check */
|
|
201
|
-
|
|
202
201
|
if (!session) return;
|
|
203
202
|
let commands = [this.network.watch(session)];
|
|
204
|
-
|
|
205
203
|
if (session.isDocument) {
|
|
206
204
|
session.on('Target.attachedToTarget', this._handleAttachedToTarget);
|
|
207
205
|
commands.push(session.send('Page.enable'), session.send('Page.setLifecycleEventsEnabled', {
|
|
@@ -216,14 +214,15 @@ export class Page {
|
|
|
216
214
|
flatten: true
|
|
217
215
|
}));
|
|
218
216
|
}
|
|
219
|
-
|
|
220
217
|
return Promise.all(commands).catch(session._handleClosedError);
|
|
221
|
-
};
|
|
218
|
+
};
|
|
222
219
|
|
|
220
|
+
// Keep track of the page's execution context id
|
|
223
221
|
_handleExecutionContextCreated = event => {
|
|
224
222
|
if (this.session.targetId === event.context.auxData.frameId) {
|
|
225
|
-
this.contextId = event.context.id;
|
|
223
|
+
this.contextId = event.context.id;
|
|
226
224
|
|
|
225
|
+
// inject global percy config as soon as possible
|
|
227
226
|
this.eval(`window.__PERCY__ = ${JSON.stringify({
|
|
228
227
|
config: this.browser.percy.config
|
|
229
228
|
})};`).catch(this.session._handleClosedError);
|