@percy/client 1.0.0 → 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/client.js ADDED
@@ -0,0 +1,348 @@
1
+ import fs from 'fs';
2
+ import PercyEnv from '@percy/env';
3
+ import { git } from '@percy/env/utils';
4
+ import logger from '@percy/logger';
5
+ import { pool, request, sha256hash, base64encode, getPackageJSON } from './utils.js'; // Default client API URL can be set with an env var for API development
6
+
7
+ const {
8
+ PERCY_CLIENT_API_URL = 'https://percy.io/api/v1'
9
+ } = process.env;
10
+ const pkg = getPackageJSON(import.meta.url); // Validate build ID arguments
11
+
12
+ function validateBuildId(id) {
13
+ if (!id) throw new Error('Missing build ID');
14
+
15
+ if (!(typeof id === 'string' || typeof id === 'number')) {
16
+ throw new Error('Invalid build ID');
17
+ }
18
+ } // Validate project path arguments
19
+
20
+
21
+ function validateProjectPath(path) {
22
+ if (!path) throw new Error('Missing project path');
23
+
24
+ if (!/^[^/]+?\/.+/.test(path)) {
25
+ throw new Error(`Invalid project path. Expected "org/project" but received "${path}"`);
26
+ }
27
+ } // PercyClient is used to communicate with the Percy API to create and finalize
28
+ // builds and snapshot. Uses @percy/env to collect environment information used
29
+ // during build creation.
30
+
31
+
32
+ export class PercyClient {
33
+ log = logger('client');
34
+ env = new PercyEnv(process.env);
35
+ clientInfo = new Set();
36
+ environmentInfo = new Set();
37
+
38
+ constructor({
39
+ // read or write token, defaults to PERCY_TOKEN environment variable
40
+ token,
41
+ // initial user agent info
42
+ clientInfo,
43
+ environmentInfo,
44
+ // versioned api url
45
+ apiUrl = PERCY_CLIENT_API_URL
46
+ } = {}) {
47
+ Object.assign(this, {
48
+ token,
49
+ apiUrl
50
+ });
51
+ this.addClientInfo(clientInfo);
52
+ this.addEnvironmentInfo(environmentInfo);
53
+ } // Adds additional unique client info.
54
+
55
+
56
+ addClientInfo(info) {
57
+ for (let i of [].concat(info)) {
58
+ if (i) this.clientInfo.add(i);
59
+ }
60
+ } // Adds additional unique environment info.
61
+
62
+
63
+ addEnvironmentInfo(info) {
64
+ for (let i of [].concat(info)) {
65
+ if (i) this.environmentInfo.add(i);
66
+ }
67
+ } // Stringifies client and environment info.
68
+
69
+
70
+ userAgent() {
71
+ let client = new Set([`Percy/${/\w+$/.exec(this.apiUrl)}`].concat(`${pkg.name}/${pkg.version}`, ...this.clientInfo).filter(Boolean));
72
+ let environment = new Set([...this.environmentInfo].concat(`node/${process.version}`, this.env.info).filter(Boolean));
73
+ return `${[...client].join(' ')} (${[...environment].join('; ')})`;
74
+ } // Checks for a Percy token and returns it.
75
+
76
+
77
+ getToken() {
78
+ let token = this.token || this.env.token;
79
+ if (!token) throw new Error('Missing Percy token');
80
+ return token;
81
+ } // Returns common headers used for each request with additional
82
+ // headers. Throws an error when the token is missing, which is a required
83
+ // authorization header.
84
+
85
+
86
+ headers(headers) {
87
+ return Object.assign({
88
+ Authorization: `Token token=${this.getToken()}`,
89
+ 'User-Agent': this.userAgent()
90
+ }, headers);
91
+ } // Performs a GET request for an API endpoint with appropriate headers.
92
+
93
+
94
+ get(path) {
95
+ return request(`${this.apiUrl}/${path}`, {
96
+ headers: this.headers(),
97
+ method: 'GET'
98
+ });
99
+ } // Performs a POST request to a JSON API endpoint with appropriate headers.
100
+
101
+
102
+ post(path, body = {}) {
103
+ return request(`${this.apiUrl}/${path}`, {
104
+ headers: this.headers({
105
+ 'Content-Type': 'application/vnd.api+json'
106
+ }),
107
+ method: 'POST',
108
+ body
109
+ });
110
+ } // Creates a build with optional build resources. Only one build can be
111
+ // created at a time per instance so snapshots and build finalization can be
112
+ // done more seemlessly without manually tracking build ids
113
+
114
+
115
+ async createBuild({
116
+ resources = []
117
+ } = {}) {
118
+ this.log.debug('Creating a new build...');
119
+ return this.post('builds', {
120
+ data: {
121
+ type: 'builds',
122
+ attributes: {
123
+ branch: this.env.git.branch,
124
+ 'target-branch': this.env.target.branch,
125
+ 'target-commit-sha': this.env.target.commit,
126
+ 'commit-sha': this.env.git.sha,
127
+ 'commit-committed-at': this.env.git.committedAt,
128
+ 'commit-author-name': this.env.git.authorName,
129
+ 'commit-author-email': this.env.git.authorEmail,
130
+ 'commit-committer-name': this.env.git.committerName,
131
+ 'commit-committer-email': this.env.git.committerEmail,
132
+ 'commit-message': this.env.git.message,
133
+ 'pull-request-number': this.env.pullRequest,
134
+ 'parallel-nonce': this.env.parallel.nonce,
135
+ 'parallel-total-shards': this.env.parallel.total,
136
+ partial: this.env.partial
137
+ },
138
+ relationships: {
139
+ resources: {
140
+ data: resources.map(r => ({
141
+ type: 'resources',
142
+ id: r.sha || sha256hash(r.content),
143
+ attributes: {
144
+ 'resource-url': r.url,
145
+ 'is-root': r.root || null,
146
+ mimetype: r.mimetype || null
147
+ }
148
+ }))
149
+ }
150
+ }
151
+ }
152
+ });
153
+ } // Finalizes the active build. When `all` is true, `all-shards=true` is
154
+ // added as a query param so the API finalizes all other build shards.
155
+
156
+
157
+ async finalizeBuild(buildId, {
158
+ all = false
159
+ } = {}) {
160
+ validateBuildId(buildId);
161
+ let qs = all ? 'all-shards=true' : '';
162
+ this.log.debug(`Finalizing build ${buildId}...`);
163
+ return this.post(`builds/${buildId}/finalize?${qs}`);
164
+ } // Retrieves build data by id. Requires a read access token.
165
+
166
+
167
+ async getBuild(buildId) {
168
+ validateBuildId(buildId);
169
+ this.log.debug(`Get build ${buildId}`);
170
+ return this.get(`builds/${buildId}`);
171
+ } // Retrieves project builds optionally filtered. Requires a read access token.
172
+
173
+
174
+ async getBuilds(project, filters = {}) {
175
+ validateProjectPath(project);
176
+ let qs = Object.keys(filters).map(k => Array.isArray(filters[k]) ? filters[k].map(v => `filter[${k}][]=${v}`).join('&') : `filter[${k}]=${filters[k]}`).join('&');
177
+ this.log.debug(`Fetching builds for ${project}`);
178
+ return this.get(`projects/${project}/builds?${qs}`);
179
+ } // Resolves when the build has finished and is no longer pending or
180
+ // processing. By default, will time out if no update after 10 minutes.
181
+
182
+
183
+ waitForBuild({
184
+ build,
185
+ project,
186
+ commit,
187
+ timeout = 10 * 60 * 1000,
188
+ interval = 1000
189
+ }, onProgress) {
190
+ if (commit && !project) {
191
+ throw new Error('Missing project path for commit');
192
+ } else if (!commit && !build) {
193
+ throw new Error('Missing build ID or commit SHA');
194
+ } else if (project) {
195
+ validateProjectPath(project);
196
+ }
197
+
198
+ let sha = commit && (git(`rev-parse ${commit}`) || commit);
199
+
200
+ let fetchData = async () => build ? (await this.getBuild(build)).data : (await this.getBuilds(project, {
201
+ sha
202
+ })).data[0];
203
+
204
+ this.log.debug(`Waiting for build ${build || `${project} (${commit})`}...`); // recursively poll every second until the build finishes
205
+
206
+ return new Promise((resolve, reject) => async function poll(last, t) {
207
+ try {
208
+ let data = await fetchData();
209
+ let state = data === null || data === void 0 ? void 0 : data.attributes.state;
210
+ let pending = !state || state === 'pending' || state === 'processing';
211
+ let updated = JSON.stringify(data) !== JSON.stringify(last); // new data received
212
+
213
+ if (updated) {
214
+ t = Date.now(); // no new data within the timeout
215
+ } else if (Date.now() - t >= timeout) {
216
+ throw new Error('Timeout exceeded without an update');
217
+ } // call progress every update after the first update
218
+
219
+
220
+ if ((last || pending) && updated) {
221
+ onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
222
+ } // not finished, poll again
223
+
224
+
225
+ if (pending) {
226
+ return setTimeout(poll, interval, data, t); // build finished
227
+ } else {
228
+ // ensure progress is called at least once
229
+ if (!last) onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
230
+ resolve({
231
+ data
232
+ });
233
+ }
234
+ } catch (err) {
235
+ reject(err);
236
+ }
237
+ }(null, Date.now()));
238
+ } // Uploads a single resource to the active build. If `filepath` is provided,
239
+ // `content` is read from the filesystem. The sha is optional and will be
240
+ // created from `content` if one is not provided.
241
+
242
+
243
+ async uploadResource(buildId, {
244
+ url,
245
+ sha,
246
+ filepath,
247
+ content
248
+ } = {}) {
249
+ validateBuildId(buildId);
250
+ this.log.debug(`Uploading resource: ${url}...`);
251
+ if (filepath) content = fs.readFileSync(filepath);
252
+ return this.post(`builds/${buildId}/resources`, {
253
+ data: {
254
+ type: 'resources',
255
+ id: sha || sha256hash(content),
256
+ attributes: {
257
+ 'base64-content': base64encode(content)
258
+ }
259
+ }
260
+ });
261
+ } // Uploads resources to the active build concurrently, two at a time.
262
+
263
+
264
+ async uploadResources(buildId, resources) {
265
+ validateBuildId(buildId);
266
+ this.log.debug(`Uploading resources for ${buildId}...`);
267
+ return pool(function* () {
268
+ for (let resource of resources) {
269
+ yield this.uploadResource(buildId, resource);
270
+ }
271
+ }, this, 2);
272
+ } // Creates a snapshot for the active build using the provided attributes.
273
+
274
+
275
+ async createSnapshot(buildId, {
276
+ name,
277
+ widths,
278
+ minHeight,
279
+ enableJavaScript,
280
+ clientInfo,
281
+ environmentInfo,
282
+ resources = []
283
+ } = {}) {
284
+ validateBuildId(buildId);
285
+ this.addClientInfo(clientInfo);
286
+ this.addEnvironmentInfo(environmentInfo);
287
+
288
+ if (!this.clientInfo.size || !this.environmentInfo.size) {
289
+ this.log.warn('Warning: Missing `clientInfo` and/or `environmentInfo` properties');
290
+ }
291
+
292
+ this.log.debug(`Creating snapshot: ${name}...`);
293
+ return this.post(`builds/${buildId}/snapshots`, {
294
+ data: {
295
+ type: 'snapshots',
296
+ attributes: {
297
+ name: name || null,
298
+ widths: widths || null,
299
+ 'minimum-height': minHeight || null,
300
+ 'enable-javascript': enableJavaScript || null
301
+ },
302
+ relationships: {
303
+ resources: {
304
+ data: resources.map(r => ({
305
+ type: 'resources',
306
+ id: r.sha || sha256hash(r.content),
307
+ attributes: {
308
+ 'resource-url': r.url || null,
309
+ 'is-root': r.root || null,
310
+ mimetype: r.mimetype || null
311
+ }
312
+ }))
313
+ }
314
+ }
315
+ }
316
+ });
317
+ } // Finalizes a snapshot.
318
+
319
+
320
+ async finalizeSnapshot(snapshotId) {
321
+ if (!snapshotId) throw new Error('Missing snapshot ID');
322
+ this.log.debug(`Finalizing snapshot ${snapshotId}...`);
323
+ return this.post(`snapshots/${snapshotId}/finalize`);
324
+ } // Convenience method for creating a snapshot for the active build, uploading
325
+ // missing resources for the snapshot, and finalizing the snapshot.
326
+
327
+
328
+ async sendSnapshot(buildId, options) {
329
+ var _snapshot$data$relati, _snapshot$data$relati2;
330
+
331
+ let snapshot = await this.createSnapshot(buildId, options);
332
+ let missing = (_snapshot$data$relati = snapshot.data.relationships) === null || _snapshot$data$relati === void 0 ? void 0 : (_snapshot$data$relati2 = _snapshot$data$relati['missing-resources']) === null || _snapshot$data$relati2 === void 0 ? void 0 : _snapshot$data$relati2.data;
333
+
334
+ if (missing !== null && missing !== void 0 && missing.length) {
335
+ let resources = options.resources.reduce((acc, r) => Object.assign(acc, {
336
+ [r.sha]: r
337
+ }), {});
338
+ await this.uploadResources(buildId, missing.map(({
339
+ id
340
+ }) => resources[id]));
341
+ }
342
+
343
+ await this.finalizeSnapshot(snapshot.data.id);
344
+ return snapshot;
345
+ }
346
+
347
+ }
348
+ export default PercyClient;
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default, PercyClient } from './client.js';
package/dist/proxy.js ADDED
@@ -0,0 +1,205 @@
1
+ import net from 'net';
2
+ import tls from 'tls';
3
+ import http from 'http';
4
+ import https from 'https';
5
+ import logger from '@percy/logger';
6
+ const CRLF = '\r\n';
7
+ const STATUS_REG = /^HTTP\/1.[01] (\d*)/; // Returns true if the URL hostname matches any patterns
8
+
9
+ export function hostnameMatches(patterns, url) {
10
+ let subject = new URL(url);
11
+ /* istanbul ignore next: only strings are provided internally by the client proxy; core (which
12
+ * borrows this util) sometimes provides an array of patterns or undefined */
13
+
14
+ patterns = typeof patterns === 'string' ? patterns.split(/[\s,]+/) : [].concat(patterns);
15
+
16
+ for (let pattern of patterns) {
17
+ if (pattern === '*') return true;
18
+ if (!pattern) continue; // parse pattern
19
+
20
+ let {
21
+ groups: rule
22
+ } = pattern.match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/); // missing a hostname or ports do not match
23
+
24
+ if (!rule.hostname || rule.port && rule.port !== subject.port) {
25
+ continue;
26
+ } // wildcards are treated the same as leading dots
27
+
28
+
29
+ rule.hostname = rule.hostname.replace(/^\*/, ''); // hostnames are equal or end with a wildcard rule
30
+
31
+ if (rule.hostname === subject.hostname || rule.hostname.startsWith('.') && subject.hostname.endsWith(rule.hostname)) {
32
+ return true;
33
+ }
34
+ }
35
+
36
+ return false;
37
+ } // Returns the port number of a URL object. Defaults to port 443 for https
38
+ // protocols or port 80 otherwise.
39
+
40
+ export function port(options) {
41
+ if (options.port) return options.port;
42
+ return options.protocol === 'https:' ? 443 : 80;
43
+ } // Returns a string representation of a URL-like object
44
+
45
+ export function href(options) {
46
+ let {
47
+ protocol,
48
+ hostname,
49
+ path,
50
+ pathname,
51
+ search,
52
+ hash
53
+ } = options;
54
+ return `${protocol}//${hostname}:${port(options)}` + (path || `${pathname || ''}${search || ''}${hash || ''}`);
55
+ }
56
+ ; // Returns the proxy URL for a set of request options
57
+
58
+ export function getProxy(options) {
59
+ let proxyUrl = options.protocol === 'https:' && (process.env.https_proxy || process.env.HTTPS_PROXY) || process.env.http_proxy || process.env.HTTP_PROXY;
60
+ let shouldProxy = !!proxyUrl && !hostnameMatches(process.env.no_proxy || process.env.NO_PROXY, href(options));
61
+
62
+ if (shouldProxy) {
63
+ proxyUrl = new URL(proxyUrl);
64
+ let isHttps = proxyUrl.protocol === 'https:';
65
+
66
+ if (!isHttps && proxyUrl.protocol !== 'http:') {
67
+ throw new Error(`Unsupported proxy protocol: ${proxyUrl.protocol}`);
68
+ }
69
+
70
+ let proxy = {
71
+ isHttps
72
+ };
73
+ proxy.auth = !!proxyUrl.username && 'Basic ' + (proxyUrl.password ? Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`) : Buffer.from(proxyUrl.username)).toString('base64');
74
+ proxy.host = proxyUrl.hostname;
75
+ proxy.port = port(proxyUrl);
76
+
77
+ proxy.connect = () => (isHttps ? tls : net).connect({
78
+ rejectUnauthorized: options.rejectUnauthorized,
79
+ host: proxy.host,
80
+ port: proxy.port
81
+ });
82
+
83
+ return proxy;
84
+ }
85
+ } // Proxified http agent
86
+
87
+ export class ProxyHttpAgent extends http.Agent {
88
+ // needed for https proxies
89
+ httpsAgent = new https.Agent({
90
+ keepAlive: true
91
+ });
92
+
93
+ addRequest(request, options) {
94
+ var _request$outputData;
95
+
96
+ let proxy = getProxy(options);
97
+ if (!proxy) return super.addRequest(request, options);
98
+ logger('client:proxy').debug(`Proxying request: ${options.href}`); // modify the request for proxying
99
+
100
+ request.path = href(options);
101
+
102
+ if (proxy.auth) {
103
+ request.setHeader('Proxy-Authorization', proxy.auth);
104
+ } // regenerate headers since we just changed things
105
+
106
+
107
+ delete request._header;
108
+
109
+ request._implicitHeader();
110
+
111
+ if (((_request$outputData = request.outputData) === null || _request$outputData === void 0 ? void 0 : _request$outputData.length) > 0) {
112
+ let first = request.outputData[0].data;
113
+ let endOfHeaders = first.indexOf(CRLF.repeat(2)) + 4;
114
+ request.outputData[0].data = request._header + first.substring(endOfHeaders);
115
+ } // coerce the connection to the proxy
116
+
117
+
118
+ options.port = proxy.port;
119
+ options.host = proxy.host;
120
+ delete options.path;
121
+
122
+ if (proxy.isHttps) {
123
+ // use the underlying https agent to complete the connection
124
+ request.agent = this.httpsAgent;
125
+ return this.httpsAgent.addRequest(request, options);
126
+ } else {
127
+ return super.addRequest(request, options);
128
+ }
129
+ }
130
+
131
+ } // Proxified https agent
132
+
133
+ export class ProxyHttpsAgent extends https.Agent {
134
+ constructor(options) {
135
+ // default keep-alive
136
+ super({
137
+ keepAlive: true,
138
+ ...options
139
+ });
140
+ }
141
+
142
+ createConnection(options, callback) {
143
+ let proxy = getProxy(options);
144
+ if (!proxy) return super.createConnection(options, callback);
145
+ logger('client:proxy').debug(`Proxying request: ${href(options)}`); // generate proxy connect message
146
+
147
+ let host = `${options.hostname}:${port(options)}`;
148
+ let connectMessage = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
149
+
150
+ if (proxy.auth) {
151
+ connectMessage.push(`Proxy-Authorization: ${proxy.auth}`);
152
+ }
153
+
154
+ connectMessage = connectMessage.join(CRLF);
155
+ connectMessage += CRLF.repeat(2); // start the proxy connection and setup listeners
156
+
157
+ let socket = proxy.connect();
158
+
159
+ let handleError = err => {
160
+ socket.destroy(err);
161
+ callback(err);
162
+ };
163
+
164
+ let handleClose = () => handleError(new Error('Connection closed while sending request to upstream proxy'));
165
+
166
+ let buffer = '';
167
+
168
+ let handleData = data => {
169
+ var _buffer$match;
170
+
171
+ buffer += data.toString(); // haven't received end of headers yet, keep buffering
172
+
173
+ if (!buffer.includes(CRLF.repeat(2))) return; // stop listening after end of headers
174
+
175
+ socket.off('data', handleData);
176
+
177
+ if (((_buffer$match = buffer.match(STATUS_REG)) === null || _buffer$match === void 0 ? void 0 : _buffer$match[1]) !== '200') {
178
+ return handleError(new Error('Error establishing proxy connection. ' + `Response from server was: ${buffer}`));
179
+ }
180
+
181
+ options.socket = socket;
182
+ options.servername = options.hostname; // callback not passed in so not to be added as a listener
183
+
184
+ callback(null, super.createConnection(options));
185
+ }; // send and handle the connect message
186
+
187
+
188
+ socket.on('error', handleError).on('close', handleClose).on('data', handleData).write(connectMessage);
189
+ }
190
+
191
+ }
192
+ export function proxyAgentFor(url, options) {
193
+ let cache = proxyAgentFor.cache || (proxyAgentFor.cache = new Map());
194
+ let {
195
+ protocol,
196
+ hostname
197
+ } = new URL(url);
198
+ let cachekey = `${protocol}//${hostname}`;
199
+
200
+ if (!cache.has(cachekey)) {
201
+ cache.set(cachekey, protocol === 'https:' ? new ProxyHttpsAgent(options) : new ProxyHttpAgent(options));
202
+ }
203
+
204
+ return cache.get(cachekey);
205
+ }
package/dist/utils.js ADDED
@@ -0,0 +1,204 @@
1
+ import os from 'os';
2
+ import fs from 'fs';
3
+ import url from 'url';
4
+ import path from 'path';
5
+ import crypto from 'crypto'; // Returns a sha256 hash of a string.
6
+
7
+ export function sha256hash(content) {
8
+ return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
9
+ } // Returns a base64 encoding of a string or buffer.
10
+
11
+ export function base64encode(content) {
12
+ return Buffer.from(content).toString('base64');
13
+ } // Returns the package.json content at the package path.
14
+
15
+ export function getPackageJSON(rel) {
16
+ /* istanbul ignore else: sanity check */
17
+ if (rel.startsWith('file:')) rel = url.fileURLToPath(rel);
18
+ let pkg = path.join(rel, 'package.json');
19
+ if (fs.existsSync(pkg)) return JSON.parse(fs.readFileSync(pkg));
20
+ let dir = path.dirname(rel);
21
+ /* istanbul ignore else: sanity check */
22
+
23
+ if (dir !== rel && dir !== os.homedir()) return getPackageJSON(dir);
24
+ } // Creates a concurrent pool of promises created by the given generator.
25
+ // Resolves when the generator's final promise resolves and rejects when any
26
+ // generated promise rejects.
27
+
28
+ export function pool(generator, context, concurrency) {
29
+ return new Promise((resolve, reject) => {
30
+ let iterator = generator.call(context);
31
+ let queue = 0;
32
+ let ret = [];
33
+ let err; // generates concurrent promises
34
+
35
+ let proceed = () => {
36
+ while (queue < concurrency) {
37
+ let {
38
+ done,
39
+ value: promise
40
+ } = iterator.next();
41
+
42
+ if (done || err) {
43
+ if (!queue && err) reject(err);
44
+ if (!queue) resolve(ret);
45
+ return;
46
+ }
47
+
48
+ queue++;
49
+ promise.then(value => {
50
+ queue--;
51
+ ret.push(value);
52
+ proceed();
53
+ }).catch(error => {
54
+ queue--;
55
+ err = error;
56
+ proceed();
57
+ });
58
+ }
59
+ }; // start generating promises
60
+
61
+
62
+ proceed();
63
+ });
64
+ } // Returns a promise that resolves or rejects when the provided function calls
65
+ // `resolve` or `reject` respectively. The third function argument, `retry`,
66
+ // will recursively call the function at the specified interval until retries
67
+ // are exhausted, at which point the promise will reject with the last error
68
+ // passed to `retry`.
69
+
70
+ export function retry(fn, {
71
+ retries = 5,
72
+ interval = 50
73
+ }) {
74
+ return new Promise((resolve, reject) => {
75
+ let run = () => fn(resolve, reject, retry); // wait an interval to try again or reject with the error
76
+
77
+
78
+ let retry = err => {
79
+ if (retries-- > 0) {
80
+ setTimeout(run, interval);
81
+ } else {
82
+ reject(err);
83
+ }
84
+ }; // start trying
85
+
86
+
87
+ run();
88
+ });
89
+ } // Used by the request util when retrying specific errors
90
+
91
+ const RETRY_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN']; // Proxified request function that resolves with the response body when the request is successful
92
+ // and rejects when a non-successful response is received. The rejected error contains response data
93
+ // and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by
94
+ // default, and 404 errors may also be optionally retried. If a callback is provided, it is called
95
+ // with the parsed response body and response details. If the callback returns a value, that value
96
+ // will be returned in the final resolved promise instead of the response body.
97
+
98
+ export async function request(url, options = {}, callback) {
99
+ // accept `request(url, callback)`
100
+ if (typeof options === 'function') [options, callback] = [{}, options]; // gather request options
101
+
102
+ let {
103
+ body,
104
+ headers,
105
+ retries,
106
+ retryNotFound,
107
+ interval,
108
+ noProxy,
109
+ ...requestOptions
110
+ } = options;
111
+ let {
112
+ protocol,
113
+ hostname,
114
+ port,
115
+ pathname,
116
+ search,
117
+ hash
118
+ } = new URL(url); // reference the default export so tests can mock it
119
+
120
+ let {
121
+ default: http
122
+ } = await import(protocol === 'https:' ? 'https' : 'http');
123
+ let {
124
+ proxyAgentFor
125
+ } = await import('./proxy.js'); // automatically stringify body content
126
+
127
+ if (body && typeof body !== 'string') {
128
+ headers = {
129
+ 'Content-Type': 'application/json',
130
+ ...headers
131
+ };
132
+ body = JSON.stringify(body);
133
+ } // combine request options
134
+
135
+
136
+ Object.assign(requestOptions, {
137
+ agent: requestOptions.agent || !noProxy && proxyAgentFor(url) || null,
138
+ path: pathname + search + hash,
139
+ protocol,
140
+ hostname,
141
+ headers,
142
+ port
143
+ });
144
+ return retry((resolve, reject, retry) => {
145
+ let handleError = error => {
146
+ if (handleError.handled) return;
147
+ handleError.handled = true; // maybe retry 404s, always retry 500s, or retry specific errors
148
+
149
+ let shouldRetry = error.response ? retryNotFound && error.response.statusCode === 404 || error.response.statusCode >= 500 && error.response.statusCode < 600 : !!error.code && RETRY_ERROR_CODES.includes(error.code);
150
+ return shouldRetry ? retry(error) : reject(error);
151
+ };
152
+
153
+ let handleFinished = async (body, res) => {
154
+ let {
155
+ statusCode,
156
+ headers
157
+ } = res;
158
+ let raw = body; // attempt to parse the body as json
159
+
160
+ try {
161
+ body = JSON.parse(body);
162
+ } catch (e) {}
163
+
164
+ try {
165
+ if (statusCode >= 200 && statusCode < 300) {
166
+ var _callback;
167
+
168
+ resolve((await ((_callback = callback) === null || _callback === void 0 ? void 0 : _callback(body, res))) ?? body);
169
+ } else {
170
+ var _body, _body$errors, _body$errors$find;
171
+
172
+ let err = (_body = body) === null || _body === void 0 ? void 0 : (_body$errors = _body.errors) === null || _body$errors === void 0 ? void 0 : (_body$errors$find = _body$errors.find(e => e.detail)) === null || _body$errors$find === void 0 ? void 0 : _body$errors$find.detail;
173
+ throw new Error(err || `${statusCode} ${res.statusMessage || raw}`);
174
+ }
175
+ } catch (error) {
176
+ let response = {
177
+ statusCode,
178
+ headers,
179
+ body
180
+ };
181
+ handleError(Object.assign(error, {
182
+ response
183
+ }));
184
+ }
185
+ };
186
+
187
+ let handleResponse = res => {
188
+ let body = '';
189
+ res.setEncoding('utf-8');
190
+ res.on('data', chunk => body += chunk);
191
+ res.on('end', () => handleFinished(body, res));
192
+ res.on('error', handleError);
193
+ };
194
+
195
+ let req = http.request(requestOptions);
196
+ req.on('response', handleResponse);
197
+ req.on('error', handleError);
198
+ req.end(body);
199
+ }, {
200
+ retries,
201
+ interval
202
+ });
203
+ }
204
+ export { hostnameMatches, ProxyHttpAgent, ProxyHttpsAgent, proxyAgentFor } from './proxy.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/client",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,8 +14,8 @@
14
14
  "node": ">=14"
15
15
  },
16
16
  "files": [
17
- "./dist",
18
- "./test/helpers.js"
17
+ "dist",
18
+ "test/helpers.js"
19
19
  ],
20
20
  "main": "./dist/index.js",
21
21
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test:coverage": "yarn test --coverage"
32
32
  },
33
33
  "dependencies": {
34
- "@percy/env": "1.0.0",
35
- "@percy/logger": "1.0.0"
34
+ "@percy/env": "1.0.1",
35
+ "@percy/logger": "1.0.1"
36
36
  },
37
- "gitHead": "6df509421a60144e4f9f5d59dc57a5675372a0b2"
37
+ "gitHead": "38917e6027299d6cd86008e2ccd005d90bbf89c0"
38
38
  }
@@ -0,0 +1,152 @@
1
+ import EventEmitter from 'events';
2
+ import url from 'url';
3
+
4
+ // Mock response class used for mock requests
5
+ export class MockResponse extends EventEmitter {
6
+ constructor(options) {
7
+ Object.assign(super(), options);
8
+ }
9
+
10
+ resume() {}
11
+ setEncoding() {}
12
+ pipe = stream => this
13
+ .on('data', d => stream.write(d))
14
+ .on('end', () => stream.end());
15
+ };
16
+
17
+ // Mock request class automates basic mocking necessities
18
+ export class MockRequest extends EventEmitter {
19
+ constructor(reply, url, opts, cb) {
20
+ // handle optional url string
21
+ if (url && typeof url === 'string') {
22
+ let { protocol, hostname, port, pathname, search, hash } = new URL(url);
23
+ opts = { ...opts, protocol, hostname, port, path: pathname + search + hash };
24
+ } else if (typeof url !== 'string') {
25
+ opts = url;
26
+ }
27
+
28
+ Object.assign(super(), opts, { reply });
29
+ if (cb) this.on('response', cb);
30
+ }
31
+
32
+ // useful for logs/tests
33
+ get url() {
34
+ return new URL(this.path, url.format(this)).href;
35
+ }
36
+
37
+ // kick off a reply response on request end
38
+ end(body) {
39
+ // process async but return sync
40
+ (async () => {
41
+ try { this.body = JSON.parse(body); } catch {}
42
+ let [statusCode, data = '', headers = {}] = await this.reply?.(this) ?? [];
43
+
44
+ if (data && typeof data !== 'string') {
45
+ // handle common json data
46
+ headers['content-type'] = headers['content-type'] || 'application/json';
47
+ data = JSON.stringify(data);
48
+ } else if (!statusCode) {
49
+ // no status code was mocked
50
+ data = `Not mocked ${this.url}`;
51
+ statusCode = 404;
52
+ }
53
+
54
+ // automate content-length header
55
+ if (data != null && !headers['content-length']) {
56
+ headers['content-length'] = Buffer.byteLength(data);
57
+ }
58
+
59
+ // create and trigger a mock response
60
+ let res = new MockResponse({ statusCode, headers });
61
+ this.emit('response', res);
62
+
63
+ // maybe delay response data
64
+ setTimeout(() => {
65
+ res.emit('data', data);
66
+ res.emit('end');
67
+ }, this.delay);
68
+ })();
69
+
70
+ return this;
71
+ }
72
+ }
73
+
74
+ // Mock request responses using jasmine spies
75
+ export async function mockRequests(baseUrl, defaultReply = () => [200]) {
76
+ let { protocol, hostname, pathname } = new URL(baseUrl);
77
+ let { default: http } = await import(protocol === 'https:' ? 'https' : 'http');
78
+
79
+ if (!jasmine.isSpy(http.request)) {
80
+ spyOn(http, 'request').and.callFake((...a) => new MockRequest(null, ...a));
81
+ spyOn(http, 'get').and.callFake((...a) => new MockRequest(null, ...a).end());
82
+ }
83
+
84
+ let any = jasmine.anything();
85
+ let match = o => o.hostname === hostname &&
86
+ (o.path ?? o.pathname).startsWith(pathname);
87
+ let reply = jasmine.createSpy('reply').and.callFake(defaultReply);
88
+
89
+ http.request.withArgs({ asymmetricMatch: match })
90
+ .and.callFake((...a) => new MockRequest(reply, ...a));
91
+ http.get.withArgs({ asymmetricMatch: u => match(new URL(u)) }, any, any)
92
+ .and.callFake((...a) => new MockRequest(reply, ...a).end());
93
+
94
+ return reply;
95
+ }
96
+
97
+ // Group of helpers to mock Percy API requests
98
+ export const api = {
99
+ DEFAULT_REPLIES: {
100
+ '/builds': () => [201, {
101
+ data: {
102
+ id: '123',
103
+ attributes: {
104
+ 'build-number': 1,
105
+ 'web-url': 'https://percy.io/test/test/123'
106
+ }
107
+ }
108
+ }],
109
+
110
+ '/builds/123/snapshots': ({ body }) => [201, {
111
+ data: {
112
+ id: '4567',
113
+ attributes: body.attributes,
114
+ relationships: {
115
+ 'missing-resources': {
116
+ data: body.data.relationships.resources
117
+ .data.map(({ id }) => ({ id }))
118
+ }
119
+ }
120
+ }
121
+ }]
122
+ },
123
+
124
+ async mock({ delay = 10 } = {}) {
125
+ this.replies = {};
126
+ this.requests = {};
127
+
128
+ await mockRequests('https://percy.io/api/v1', req => {
129
+ let path = req.path.replace('/api/v1', '');
130
+
131
+ let reply = (this.replies[path] && (
132
+ this.replies[path].length > 1
133
+ ? this.replies[path].shift()
134
+ : this.replies[path][0]
135
+ )) || this.DEFAULT_REPLIES[path];
136
+
137
+ this.requests[path] = this.requests[path] || [];
138
+ this.requests[path].push(req);
139
+
140
+ if (delay) req.delay = delay;
141
+ return reply?.(req) ?? [200];
142
+ });
143
+ },
144
+
145
+ reply(path, handler) {
146
+ this.replies[path] = this.replies[path] || [];
147
+ this.replies[path].push(handler);
148
+ return this;
149
+ }
150
+ };
151
+
152
+ export default api;