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