@percy/core 1.0.0-beta.8 → 1.0.1

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/index.js CHANGED
@@ -1,29 +1,5 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- var _exportNames = {};
7
- Object.defineProperty(exports, "default", {
8
- enumerable: true,
9
- get: function () {
10
- return _percy.default;
11
- }
12
- });
13
-
14
- var _percy = _interopRequireDefault(require("./percy"));
15
-
16
- var _resources = require("./utils/resources");
17
-
18
- Object.keys(_resources).forEach(function (key) {
19
- if (key === "default" || key === "__esModule") return;
20
- if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
21
- Object.defineProperty(exports, key, {
22
- enumerable: true,
23
- get: function () {
24
- return _resources[key];
25
- }
26
- });
27
- });
28
-
29
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
1
+ import PercyConfig from '@percy/config';
2
+ import * as CoreConfig from './config.js';
3
+ PercyConfig.addSchema(CoreConfig.schemas);
4
+ PercyConfig.addMigration(CoreConfig.migrations);
5
+ export { default, Percy } from './percy.js';
@@ -0,0 +1,156 @@
1
+ import fs from 'fs';
2
+ import url from 'url';
3
+ import path from 'path';
4
+ import https from 'https';
5
+ import logger from '@percy/logger';
6
+ import { ProxyHttpsAgent } from '@percy/client/utils'; // Formats a raw byte integer as a string
7
+
8
+ function formatBytes(int) {
9
+ let units = ['kB', 'MB', 'GB'];
10
+ let base = 1024;
11
+ let u = -1;
12
+ if (Math.abs(int) < base) return `${int}B`;
13
+
14
+ while (Math.abs(int) >= base && u++ < 2) int /= base;
15
+
16
+ return `${int.toFixed(1)}${units[u]}`;
17
+ } // Formats milleseconds as "MM:SS"
18
+
19
+
20
+ function formatTime(ms) {
21
+ let minutes = (ms / 1000 / 60).toString().split('.')[0].padStart(2, '0');
22
+ let seconds = (ms / 1000 % 60).toFixed().padStart(2, '0');
23
+ return `${minutes}:${seconds}`;
24
+ } // Formats progress as ":prefix [:bar] :ratio :percent :eta"
25
+
26
+
27
+ function formatProgress(prefix, total, start, progress) {
28
+ let width = 20;
29
+ let ratio = progress === total ? 1 : Math.min(Math.max(progress / total, 0), 1);
30
+ let percent = Math.floor(ratio * 100).toFixed(0);
31
+ let barLen = Math.round(width * ratio);
32
+ let barContent = Array(Math.max(0, barLen + 1)).join('=') + Array(Math.max(0, width - barLen + 1)).join(' ');
33
+ let elapsed = Date.now() - start;
34
+ let eta = ratio >= 1 ? 0 : elapsed * (total / progress - 1);
35
+ return `${prefix} [${barContent}] ` + `${formatBytes(progress)}/${formatBytes(total)} ` + `${percent}% ${formatTime(eta)}`;
36
+ } // Returns an item from the map keyed by the current platform
37
+
38
+
39
+ export function selectByPlatform(map) {
40
+ let {
41
+ platform,
42
+ arch
43
+ } = process;
44
+ if (platform === 'win32' && arch === 'x64') platform = 'win64';
45
+ if (platform === 'darwin' && arch === 'arm64') platform = 'darwinArm';
46
+ return map[platform];
47
+ } // Downloads and extracts an executable from a url into a local directory, returning the full path
48
+ // to the extracted binary. Skips installation if the executable already exists at the binary path.
49
+
50
+ export async function download({
51
+ name,
52
+ revision,
53
+ url,
54
+ extract,
55
+ directory,
56
+ executable
57
+ }) {
58
+ let outdir = path.join(directory, revision);
59
+ let archive = path.join(outdir, decodeURIComponent(url.split('/').pop()));
60
+ let exec = path.join(outdir, executable);
61
+
62
+ if (!fs.existsSync(exec)) {
63
+ let log = logger('core:install');
64
+ let premsg = `Downloading ${name} ${revision}`;
65
+ log.progress(`${premsg}...`);
66
+
67
+ try {
68
+ // ensure the out directory exists
69
+ await fs.promises.mkdir(outdir, {
70
+ recursive: true
71
+ }); // download the file at the given URL
72
+
73
+ await new Promise((resolve, reject) => https.get(url, {
74
+ agent: new ProxyHttpsAgent() // allow proxied requests
75
+
76
+ }, response => {
77
+ // on failure, resume the response before rejecting
78
+ if (response.statusCode !== 200) {
79
+ response.resume();
80
+ reject(new Error(`Download failed: ${response.statusCode} - ${url}`));
81
+ return;
82
+ } // log progress
83
+
84
+
85
+ if (log.shouldLog('info') && logger.stdout.isTTY) {
86
+ let total = parseInt(response.headers['content-length'], 10);
87
+ let start, progress;
88
+ response.on('data', chunk => {
89
+ start ?? (start = Date.now());
90
+ progress = (progress ?? 0) + chunk.length;
91
+ log.progress(formatProgress(premsg, total, start, progress));
92
+ });
93
+ } // pipe the response directly to a file
94
+
95
+
96
+ response.pipe(fs.createWriteStream(archive).on('finish', resolve).on('error', reject));
97
+ }).on('error', reject)); // extract the downloaded file
98
+
99
+ await extract(archive, outdir); // log success
100
+
101
+ log.info(`Successfully downloaded ${name} ${revision}`);
102
+ } finally {
103
+ // always cleanup the archive
104
+ if (fs.existsSync(archive)) {
105
+ await fs.promises.unlink(archive);
106
+ }
107
+ }
108
+ } // return the path to the executable
109
+
110
+
111
+ return exec;
112
+ } // Installs a revision of Chromium to a local directory
113
+
114
+ export function chromium({
115
+ // default directory is within @percy/core package root
116
+ directory = path.resolve(url.fileURLToPath(import.meta.url), '../../.local-chromium'),
117
+ // default chromium revision by platform (see chromium.revisions)
118
+ revision = selectByPlatform(chromium.revisions)
119
+ } = {}) {
120
+ let extract = (i, o) => import('extract-zip').then(ex => ex.default(i, {
121
+ dir: o
122
+ }));
123
+
124
+ let url = 'https://storage.googleapis.com/chromium-browser-snapshots/' + selectByPlatform({
125
+ linux: `Linux_x64/${revision}/chrome-linux.zip`,
126
+ darwin: `Mac/${revision}/chrome-mac.zip`,
127
+ darwinArm: `Mac_Arm/${revision}/chrome-mac.zip`,
128
+ win64: `Win_x64/${revision}/chrome-win.zip`,
129
+ win32: `Win/${revision}/chrome-win.zip`
130
+ });
131
+ let executable = selectByPlatform({
132
+ linux: path.join('chrome-linux', 'chrome'),
133
+ win64: path.join('chrome-win', 'chrome.exe'),
134
+ win32: path.join('chrome-win', 'chrome.exe'),
135
+ darwin: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
136
+ darwinArm: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
137
+ });
138
+ return download({
139
+ name: 'Chromium',
140
+ revision,
141
+ url,
142
+ extract,
143
+ directory,
144
+ executable
145
+ });
146
+ } // default chromium revisions corresponds to v92.0.4515.x
147
+
148
+ chromium.revisions = {
149
+ linux: '885264',
150
+ win64: '885282',
151
+ win32: '885263',
152
+ darwin: '885263',
153
+ darwinArm: '885282'
154
+ }; // export the namespace by default
155
+
156
+ export * as default from './install.js';
@@ -0,0 +1,298 @@
1
+ import logger from '@percy/logger';
2
+ import { waitFor } from './utils.js';
3
+ import { createRequestHandler, createRequestFinishedHandler, createRequestFailedHandler } from './discovery.js'; // The Interceptor class creates common handlers for dealing with intercepting asset requests
4
+ // for a given page using various devtools protocol events and commands.
5
+
6
+ export class Network {
7
+ static TIMEOUT = 30000;
8
+ log = logger('core:network');
9
+ #pending = new Map();
10
+ #requests = new Map();
11
+ #intercepts = new Map();
12
+ #authentications = new Set();
13
+
14
+ constructor(page, options) {
15
+ this.page = page;
16
+ this.timeout = options.networkIdleTimeout ?? 100;
17
+ this.authorization = options.authorization;
18
+ this.requestHeaders = options.requestHeaders ?? {};
19
+ this.userAgent = options.userAgent ?? // by default, emulate a non-headless browser
20
+ page.session.browser.version.userAgent.replace('Headless', '');
21
+ this.interceptEnabled = !!options.intercept;
22
+ this.meta = options.meta;
23
+
24
+ if (this.interceptEnabled) {
25
+ this.onRequest = createRequestHandler(this, options.intercept);
26
+ this.onRequestFinished = createRequestFinishedHandler(this, options.intercept);
27
+ this.onRequestFailed = createRequestFailedHandler(this, options.intercept);
28
+ }
29
+ }
30
+
31
+ watch(session) {
32
+ session.on('Network.requestWillBeSent', this._handleRequestWillBeSent);
33
+ session.on('Network.responseReceived', this._handleResponseReceived.bind(this, session));
34
+ session.on('Network.eventSourceMessageReceived', this._handleEventSourceMessageReceived);
35
+ session.on('Network.loadingFinished', this._handleLoadingFinished);
36
+ session.on('Network.loadingFailed', this._handleLoadingFailed);
37
+ let commands = [session.send('Network.enable'), session.send('Network.setBypassServiceWorker', {
38
+ bypass: true
39
+ }), session.send('Network.setCacheDisabled', {
40
+ cacheDisabled: true
41
+ }), session.send('Network.setUserAgentOverride', {
42
+ userAgent: this.userAgent
43
+ }), session.send('Network.setExtraHTTPHeaders', {
44
+ headers: this.requestHeaders
45
+ })];
46
+
47
+ if (this.interceptEnabled && session.isDocument) {
48
+ session.on('Fetch.requestPaused', this._handleRequestPaused.bind(this, session));
49
+ session.on('Fetch.authRequired', this._handleAuthRequired.bind(this, session));
50
+ commands.push(session.send('Fetch.enable', {
51
+ handleAuthRequests: true,
52
+ patterns: [{
53
+ urlPattern: '*'
54
+ }]
55
+ }));
56
+ }
57
+
58
+ return Promise.all(commands);
59
+ } // Resolves after the timeout when there are no more in-flight requests.
60
+
61
+
62
+ async idle(filter = () => true, timeout = this.timeout) {
63
+ let requests = [];
64
+ this.log.debug(`Wait for ${timeout}ms idle`, this.meta);
65
+ await waitFor(() => {
66
+ if (this.page.session.closedReason) {
67
+ throw new Error(`Network error: ${this.page.session.closedReason}`);
68
+ }
69
+
70
+ requests = Array.from(this.#requests.values()).filter(filter);
71
+ return requests.length === 0;
72
+ }, {
73
+ timeout: Network.TIMEOUT,
74
+ idle: timeout
75
+ }).catch(error => {
76
+ // throw a better timeout error
77
+ if (error.message.startsWith('Timeout')) {
78
+ let msg = 'Timed out waiting for network requests to idle.';
79
+
80
+ if (this.log.shouldLog('debug')) {
81
+ msg += `\n\n ${['Active requests:', ...requests.map(r => r.url)].join('\n - ')}\n`;
82
+ }
83
+
84
+ throw new Error(msg);
85
+ } else {
86
+ throw error;
87
+ }
88
+ });
89
+ } // Called when a request should be removed from various trackers
90
+
91
+
92
+ _forgetRequest({
93
+ requestId,
94
+ interceptId
95
+ }, keepPending) {
96
+ this.#requests.delete(requestId);
97
+ this.#authentications.delete(interceptId);
98
+
99
+ if (!keepPending) {
100
+ this.#pending.delete(requestId);
101
+ this.#intercepts.delete(requestId);
102
+ }
103
+ } // Called when a request requires authentication. Responds to the auth request with any
104
+ // provided authorization credentials.
105
+
106
+
107
+ _handleAuthRequired = async (session, event) => {
108
+ let {
109
+ username,
110
+ password
111
+ } = this.authorization ?? {};
112
+ let {
113
+ requestId
114
+ } = event;
115
+ let response = 'Default';
116
+
117
+ if (this.#authentications.has(requestId)) {
118
+ response = 'CancelAuth';
119
+ } else if (username || password) {
120
+ response = 'ProvideCredentials';
121
+ this.#authentications.add(requestId);
122
+ }
123
+
124
+ await session.send('Fetch.continueWithAuth', {
125
+ requestId: event.requestId,
126
+ authChallengeResponse: {
127
+ response,
128
+ username,
129
+ password
130
+ }
131
+ });
132
+ }; // Called when a request is made. The request is paused until it is fulfilled, continued, or
133
+ // aborted. If the request is already pending, handle it; otherwise set it to be intercepted.
134
+
135
+ _handleRequestPaused = async (session, event) => {
136
+ let {
137
+ networkId: requestId,
138
+ requestId: interceptId,
139
+ resourceType
140
+ } = event;
141
+ let pending = this.#pending.get(requestId);
142
+ this.#pending.delete(requestId); // guard against redirects with the same requestId
143
+
144
+ if ((pending === null || pending === void 0 ? void 0 : pending.request.url) === event.request.url && pending.request.method === event.request.method) {
145
+ await this._handleRequest(session, { ...pending,
146
+ resourceType,
147
+ interceptId
148
+ });
149
+ } else {
150
+ // track the session that intercepted the request
151
+ this.#intercepts.set(requestId, { ...event,
152
+ session
153
+ });
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
+
158
+ _handleRequestWillBeSent = async event => {
159
+ let {
160
+ requestId,
161
+ request
162
+ } = event; // do not handle data urls
163
+
164
+ if (request.url.startsWith('data:')) return;
165
+
166
+ if (this.interceptEnabled) {
167
+ let intercept = this.#intercepts.get(requestId);
168
+ this.#pending.set(requestId, event);
169
+
170
+ if (intercept) {
171
+ // handle the request with the session that intercepted it
172
+ let {
173
+ session,
174
+ requestId: interceptId,
175
+ resourceType
176
+ } = intercept;
177
+ await this._handleRequest(session, { ...event,
178
+ resourceType,
179
+ interceptId
180
+ });
181
+ this.#intercepts.delete(requestId);
182
+ }
183
+ }
184
+ }; // Called when a pending request is paused. Handles associating redirected requests with
185
+ // responses and calls this.onrequest with request info and callbacks to continue, respond,
186
+ // or abort a request. One of the callbacks is required to be called and only one.
187
+
188
+ _handleRequest = async (session, event) => {
189
+ var _this$onRequest;
190
+
191
+ let {
192
+ request,
193
+ requestId,
194
+ interceptId,
195
+ resourceType
196
+ } = event;
197
+ let redirectChain = []; // if handling a redirected request, associate the response and add to its redirect chain
198
+
199
+ if (event.redirectResponse && this.#requests.has(requestId)) {
200
+ let req = this.#requests.get(requestId);
201
+ redirectChain = [...req.redirectChain, req]; // clean up interim requests
202
+
203
+ this._forgetRequest(req, true);
204
+ }
205
+
206
+ request.type = resourceType;
207
+ request.requestId = requestId;
208
+ request.interceptId = interceptId;
209
+ request.redirectChain = redirectChain;
210
+ this.#requests.set(requestId, request);
211
+ await ((_this$onRequest = this.onRequest) === null || _this$onRequest === void 0 ? void 0 : _this$onRequest.call(this, { ...request,
212
+ // call to continue the request as-is
213
+ continue: () => session.send('Fetch.continueRequest', {
214
+ requestId: interceptId
215
+ }),
216
+ // call to respond with a specific status, content, and headers
217
+ respond: ({
218
+ status,
219
+ content,
220
+ headers
221
+ }) => session.send('Fetch.fulfillRequest', {
222
+ requestId: interceptId,
223
+ responseCode: status || 200,
224
+ body: Buffer.from(content).toString('base64'),
225
+ responseHeaders: Object.entries(headers || {}).map(([name, value]) => {
226
+ return {
227
+ name: name.toLowerCase(),
228
+ value: String(value)
229
+ };
230
+ })
231
+ }),
232
+ // call to fail or abort the request
233
+ abort: error => session.send('Fetch.failRequest', {
234
+ requestId: interceptId,
235
+ // istanbul note: this check used to be necessary and might be again in the future if we
236
+ // ever need to abort a request due to reasons other than failures
237
+ errorReason: error ? 'Failed' :
238
+ /* istanbul ignore next */
239
+ 'Aborted'
240
+ })
241
+ }));
242
+ }; // Called when a response has been received for a specific request. Associates the response with
243
+ // the request data and adds a buffer method to fetch the response body when needed.
244
+
245
+ _handleResponseReceived = (session, event) => {
246
+ let {
247
+ requestId,
248
+ response
249
+ } = event;
250
+ let request = this.#requests.get(requestId);
251
+ /* istanbul ignore if: race condition paranioa */
252
+
253
+ if (!request) return;
254
+ request.response = response;
255
+
256
+ request.response.buffer = async () => {
257
+ let result = await session.send('Network.getResponseBody', {
258
+ requestId
259
+ });
260
+ return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8');
261
+ };
262
+ }; // Called when a request streams events. These types of requests break asset discovery because
263
+ // they never finish loading, so we untrack them to signal idle after the first event.
264
+
265
+ _handleEventSourceMessageReceived = event => {
266
+ let request = this.#requests.get(event.requestId);
267
+ /* istanbul ignore else: race condition paranioa */
268
+
269
+ if (request) this._forgetRequest(request);
270
+ }; // Called when a request has finished loading which triggers the this.onrequestfinished
271
+ // callback. The request should have an associated response and be finished with any redirects.
272
+
273
+ _handleLoadingFinished = async event => {
274
+ var _this$onRequestFinish;
275
+
276
+ let request = this.#requests.get(event.requestId);
277
+ /* istanbul ignore if: race condition paranioa */
278
+
279
+ if (!request) return;
280
+ await ((_this$onRequestFinish = this.onRequestFinished) === null || _this$onRequestFinish === void 0 ? void 0 : _this$onRequestFinish.call(this, request));
281
+
282
+ this._forgetRequest(request);
283
+ }; // Called when a request has failed loading and triggers the this.onrequestfailed callback.
284
+
285
+ _handleLoadingFailed = async event => {
286
+ var _this$onRequestFailed;
287
+
288
+ let request = this.#requests.get(event.requestId);
289
+ /* istanbul ignore if: race condition paranioa */
290
+
291
+ if (!request) return;
292
+ request.error = event.errorText;
293
+ await ((_this$onRequestFailed = this.onRequestFailed) === null || _this$onRequestFailed === void 0 ? void 0 : _this$onRequestFailed.call(this, request));
294
+
295
+ this._forgetRequest(request);
296
+ };
297
+ }
298
+ export default Network;