@percy/client 1.0.0-beta.7 → 1.0.0-beta.73

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/README.md CHANGED
@@ -1,51 +1,82 @@
1
1
  # @percy/client
2
2
 
3
3
  Communicate with Percy's API to create builds and snapshots, upload resources, and finalize builds
4
- and snapshots. Uses `@percy/env` to send environment information with new builds. Can also be used
5
- to query for a project's builds using a read access token.
4
+ and snapshots. Uses [`@percy/env`](.packages/env) to send environment information with new
5
+ builds. Can also be used to query for a project's builds using a read access token.
6
6
 
7
- ## Usage
7
+ - [Usage](#usage)
8
+ - [Create a build](#create-a-build)
9
+ - [Create, upload, and finalize snapshots](#create-upload-and-finalize-snapshots)
10
+ - [Finalize a build](#finalize-a-build)
11
+ - [Query for a build*](#query-for-a-build)
12
+ - [Query for a project's builds*](#query-for-a-projects-builds)
13
+ - [Wait for a build to be finished*](#wait-for-a-build-to-be-finished)
8
14
 
9
- ### `new PercyClient([options])`
15
+ ## Usage
10
16
 
11
17
  ``` js
12
- import PercyClient from '@percy/client';
18
+ import PercyClient from '@percy/client'
13
19
 
14
- // provide a read or write token, defaults to PERCY_TOKEN environment variable
15
- const client = new PercyClient({ token: 'abcdef123456' })
20
+ const client = new PercyClient(options)
16
21
  ```
17
22
 
18
- ### Create a build
23
+ #### Options
24
+
25
+ - `token` — Your project's `PERCY_TOKEN` (**default** `process.env.PERCY_TOKEN`)
26
+ - `clientInfo` — Client info sent to Percy via a user-agent string
27
+ - `environmentInfo` — Environment info also sent with the user-agent string
28
+
29
+ ## Create a build
30
+
31
+ Creates a percy build. Only one build can be created at a time per instance. During this step,
32
+ various environment information is collected via [`@percy/env`](./packages/env#readme) and
33
+ associated with the new build. If `PERCY_PARALLEL_TOTAL` and `PERCY_PARALLEL_NONCE` are present, a
34
+ build shard is created as part of a parallelized Percy build.
19
35
 
20
36
  ``` js
21
37
  await client.createBuild()
22
38
  ```
23
39
 
24
- ### Create, upload, and finalize snapshots
40
+ ## Create, upload, and finalize snapshots
41
+
42
+ This method combines the work of creating a snapshot, uploading any missing resources, and finally
43
+ finalizng the snapshot.
25
44
 
26
45
  ``` js
27
- await client.sendSnapshot({
28
- name,
29
- widths,
30
- minimumHeight,
31
- enableJavaScript,
32
- clientInfo,
33
- environmentInfo,
34
- // `sha` falls back to `content` sha
35
- resources: [{ url, sha, content, mimetype, root }]
36
- })
46
+ await client.sendSnapshot(buildId, snapshotOptions)
37
47
  ```
38
48
 
39
- ### Finalize a build
49
+ #### Options
50
+
51
+ - `name` — Snapshot name
52
+ - `widths` — Widths to take screenshots at
53
+ - `minHeight` — Miniumum screenshot height
54
+ - `enableJavaScript` — Enable JavaScript for screenshots
55
+ - `clientInfo` — Additional client info
56
+ - `environmentInfo` — Additional environment info
57
+ - `resources` — Array of snapshot resources
58
+ - `url` — Resource URL (**required**)
59
+ - `mimetype` — Resource mimetype (**required**)
60
+ - `content` — Resource content (**required**)
61
+ - `sha` — Resource content sha
62
+ - `root` — Boolean indicating a root resource
63
+
64
+ ## Finalize a build
65
+
66
+ Finalizes a build. When `all` is true, `all-shards=true` is added as a query param so the
67
+ API finalizes all other parallel build shards associated with the build.
40
68
 
41
69
  ``` js
42
- await client.finalizeBuild()
70
+ // finalize a build
71
+ await client.finalizeBuild(buildId)
43
72
 
44
73
  // finalize all parallel build shards
45
- await client.finalizeBuild({ all: true })
74
+ await client.finalizeBuild(buildId, { all: true })
46
75
  ```
47
76
 
48
- ### Query for a build
77
+ ## Query for a build
78
+
79
+ Retrieves build data by id.
49
80
 
50
81
  **Requires a read access token**
51
82
 
@@ -53,19 +84,51 @@ await client.finalizeBuild({ all: true })
53
84
  await client.getBuild(buildId)
54
85
  ```
55
86
 
56
- ### Query for a project's builds
87
+ ## Query for a project's builds
88
+
89
+ Retrieves project builds, optionally filtered. The project slug can be found as part of the
90
+ project's URL. For example, the project slug for `https://percy.io/percy/example` is
91
+ `"percy/example"`.
57
92
 
58
93
  **Requires a read access token**
59
94
 
60
95
  ``` js
61
- await client.getBuilds(projectSlug/*, filters*/)
96
+ // get all builds for a project
97
+ await client.getBuilds(projectSlug)
98
+
99
+ // get all builds for a project's "master" branch
100
+ await client.getBuilds(projectSlug, { branch: 'master' })
62
101
  ```
63
102
 
64
- ### Wait for a build to be finished
103
+ #### Filters
104
+
105
+ - `sha` — A single commit sha
106
+ - `shas` — An array of commit shas
107
+ - `branch` — The name of a branch
108
+ - `state` — The build state (`"pending"`, `"finished"`, etc.)
109
+
110
+ ## Wait for a build to be finished
111
+
112
+ This method resolves when the build has finished and is no longer pending or processing. By default,
113
+ it will time out if there is no update after 10 minutes.
65
114
 
66
115
  **Requires a read access token**
67
116
 
68
117
  ``` js
69
- await client.waitForBuild({ build: 'build-id' })
70
- await client.waitForBuild({ project: 'project-slug', commit: '40-char-sha' })
118
+ // wait for a specific project build by commit sha
119
+ await client.waitForBuild({
120
+ project: 'percy/example',
121
+ commit: '40-char-sha'
122
+ }, data => {
123
+ // called whenever data changes
124
+ console.log(JSON.stringify(data));
125
+ })
71
126
  ```
127
+
128
+ #### Options
129
+
130
+ - `build` — Build ID (**required** when missing `commit`)
131
+ - `commit` — Commit SHA (**required** when missing `build`)
132
+ - `project` — Project slug (**required** when using `commit`)
133
+ - `timeout` — Timeout in milliseconds to wait with no updates (**default** `10 * 60 * 1000`)
134
+ - `interval` — Interval in miliseconds to check for updates (**default** `1000`)
package/dist/client.js CHANGED
@@ -3,66 +3,94 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.default = void 0;
6
+ exports.default = exports.PercyClient = void 0;
7
7
 
8
8
  var _env = _interopRequireDefault(require("@percy/env"));
9
9
 
10
- var _git = require("@percy/env/dist/git");
10
+ var _utils = require("@percy/env/dist/utils");
11
+
12
+ var _logger = _interopRequireDefault(require("@percy/logger"));
11
13
 
12
14
  var _package = _interopRequireDefault(require("../package.json"));
13
15
 
14
- var _utils = require("./utils");
16
+ var _utils2 = require("./utils");
17
+
18
+ var _request = _interopRequireDefault(require("./request"));
15
19
 
16
20
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
21
 
18
- // PercyClient is used to communicate with the Percy API to create and finalize
22
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
23
+
24
+ // Default client API URL can be set with an env var for API development
25
+ const {
26
+ PERCY_CLIENT_API_URL = 'https://percy.io/api/v1'
27
+ } = process.env; // Validate build ID arguments
28
+
29
+ function validateBuildId(id) {
30
+ if (!id) throw new Error('Missing build ID');
31
+
32
+ if (!(typeof id === 'string' || typeof id === 'number')) {
33
+ throw new Error('Invalid build ID');
34
+ }
35
+ } // Validate project path arguments
36
+
37
+
38
+ function validateProjectPath(path) {
39
+ if (!path) throw new Error('Missing project path');
40
+
41
+ if (!/^[^/]+?\/.+/.test(path)) {
42
+ throw new Error(`Invalid project path. Expected "org/project" but received "${path}"`);
43
+ }
44
+ } // PercyClient is used to communicate with the Percy API to create and finalize
19
45
  // builds and snapshot. Uses @percy/env to collect environment information used
20
46
  // during build creation.
47
+
48
+
21
49
  class PercyClient {
22
50
  constructor({
23
51
  // read or write token, defaults to PERCY_TOKEN environment variable
24
52
  token,
25
53
  // initial user agent info
26
- clientInfo = '',
27
- environmentInfo = '',
28
- // versioned percy api url
29
- apiUrl = 'https://percy.io/api/v1'
54
+ clientInfo,
55
+ environmentInfo,
56
+ // versioned api url
57
+ apiUrl = PERCY_CLIENT_API_URL
30
58
  } = {}) {
59
+ _defineProperty(this, "log", (0, _logger.default)('client'));
60
+
61
+ _defineProperty(this, "env", new _env.default(process.env));
62
+
63
+ _defineProperty(this, "clientInfo", new Set());
64
+
65
+ _defineProperty(this, "environmentInfo", new Set());
66
+
31
67
  Object.assign(this, {
32
68
  token,
33
- apiUrl,
34
- httpAgent: (0, _utils.httpAgentFor)(apiUrl),
35
- clientInfo: [].concat(clientInfo),
36
- environmentInfo: [].concat(environmentInfo),
37
- env: new _env.default(process.env),
38
- // build info is stored for reference
39
- build: {
40
- id: null,
41
- number: null,
42
- url: null
43
- }
69
+ apiUrl
44
70
  });
71
+ this.addClientInfo(clientInfo);
72
+ this.addEnvironmentInfo(environmentInfo);
45
73
  } // Adds additional unique client info.
46
74
 
47
75
 
48
76
  addClientInfo(info) {
49
- if (info && this.clientInfo.indexOf(info) === -1) {
50
- this.clientInfo.push(info);
77
+ for (let i of [].concat(info)) {
78
+ if (i) this.clientInfo.add(i);
51
79
  }
52
80
  } // Adds additional unique environment info.
53
81
 
54
82
 
55
83
  addEnvironmentInfo(info) {
56
- if (info && this.environmentInfo.indexOf(info) === -1) {
57
- this.environmentInfo.push(info);
84
+ for (let i of [].concat(info)) {
85
+ if (i) this.environmentInfo.add(i);
58
86
  }
59
87
  } // Stringifies client and environment info.
60
88
 
61
89
 
62
90
  userAgent() {
63
- let client = [`Percy/${/\w+$/.exec(this.apiUrl)}`].concat(`${_package.default.name}/${_package.default.version}`, this.clientInfo).filter(Boolean).join(' ');
64
- let environment = this.environmentInfo.concat([`node/${process.version}`, this.env.info]).filter(Boolean).join('; ');
65
- return `${client} (${environment})`;
91
+ let client = new Set([`Percy/${/\w+$/.exec(this.apiUrl)}`].concat(`${_package.default.name}/${_package.default.version}`, ...this.clientInfo).filter(Boolean));
92
+ let environment = new Set([...this.environmentInfo].concat(`node/${process.version}`, this.env.info).filter(Boolean));
93
+ return `${[...client].join(' ')} (${[...environment].join('; ')})`;
66
94
  } // Checks for a Percy token and returns it.
67
95
 
68
96
 
@@ -84,36 +112,21 @@ class PercyClient {
84
112
 
85
113
 
86
114
  get(path) {
87
- return (0, _utils.request)(`${this.apiUrl}/${path}`, {
115
+ return (0, _request.default)(`${this.apiUrl}/${path}`, {
88
116
  method: 'GET',
89
- agent: this.httpAgent,
90
117
  headers: this.headers()
91
118
  });
92
119
  } // Performs a POST request to a JSON API endpoint with appropriate headers.
93
120
 
94
121
 
95
122
  post(path, body = {}) {
96
- return (0, _utils.request)(`${this.apiUrl}/${path}`, {
123
+ return (0, _request.default)(`${this.apiUrl}/${path}`, {
97
124
  method: 'POST',
98
- agent: this.httpAgent,
99
125
  body: JSON.stringify(body),
100
126
  headers: this.headers({
101
127
  'Content-Type': 'application/vnd.api+json'
102
128
  })
103
129
  });
104
- } // Sets build reference data or nullifies it when no data is provided.
105
-
106
-
107
- setBuildData(data) {
108
- var _data$attributes, _data$attributes2;
109
-
110
- return Object.assign(this, {
111
- build: {
112
- id: data === null || data === void 0 ? void 0 : data.id,
113
- number: data === null || data === void 0 ? void 0 : (_data$attributes = data.attributes) === null || _data$attributes === void 0 ? void 0 : _data$attributes['build-number'],
114
- url: data === null || data === void 0 ? void 0 : (_data$attributes2 = data.attributes) === null || _data$attributes2 === void 0 ? void 0 : _data$attributes2['web-url']
115
- }
116
- });
117
130
  } // Creates a build with optional build resources. Only one build can be
118
131
  // created at a time per instance so snapshots and build finalization can be
119
132
  // done more seemlessly without manually tracking build ids
@@ -122,11 +135,8 @@ class PercyClient {
122
135
  async createBuild({
123
136
  resources = []
124
137
  } = {}) {
125
- if (this.build.id) {
126
- throw new Error('This client instance has not finalized the previous build');
127
- }
128
-
129
- let body = await this.post('builds', {
138
+ this.log.debug('Creating a new build...');
139
+ return this.post('builds', {
130
140
  data: {
131
141
  type: 'builds',
132
142
  attributes: {
@@ -149,7 +159,7 @@ class PercyClient {
149
159
  resources: {
150
160
  data: resources.map(r => ({
151
161
  type: 'resources',
152
- id: r.sha || (0, _utils.sha256hash)(r.content),
162
+ id: r.sha || (0, _utils2.sha256hash)(r.content),
153
163
  attributes: {
154
164
  'resource-url': r.url,
155
165
  'is-root': r.root || null,
@@ -160,34 +170,32 @@ class PercyClient {
160
170
  }
161
171
  }
162
172
  });
163
- this.setBuildData(body === null || body === void 0 ? void 0 : body.data);
164
- return body;
165
173
  } // Finalizes the active build. When `all` is true, `all-shards=true` is
166
174
  // added as a query param so the API finalizes all other build shards.
167
175
 
168
176
 
169
- async finalizeBuild({
177
+ async finalizeBuild(buildId, {
170
178
  all = false
171
179
  } = {}) {
172
- if (!this.build.id) {
173
- throw new Error('This client instance has no active build');
174
- }
175
-
180
+ validateBuildId(buildId);
176
181
  let qs = all ? 'all-shards=true' : '';
177
- let body = await this.post(`builds/${this.build.id}/finalize?${qs}`);
178
- this.setBuildData();
179
- return body;
182
+ this.log.debug(`Finalizing build ${buildId}...`);
183
+ return this.post(`builds/${buildId}/finalize?${qs}`);
180
184
  } // Retrieves build data by id. Requires a read access token.
181
185
 
182
186
 
183
187
  async getBuild(buildId) {
188
+ validateBuildId(buildId);
189
+ this.log.debug(`Get build ${buildId}`);
184
190
  return this.get(`builds/${buildId}`);
185
191
  } // Retrieves project builds optionally filtered. Requires a read access token.
186
192
 
187
193
 
188
- async getBuilds(projectSlug, filters = {}) {
194
+ async getBuilds(project, filters = {}) {
195
+ validateProjectPath(project);
189
196
  let qs = Object.keys(filters).map(k => Array.isArray(filters[k]) ? filters[k].map(v => `filter[${k}][]=${v}`).join('&') : `filter[${k}]=${filters[k]}`).join('&');
190
- return this.get(`projects/${projectSlug}/builds?${qs}`);
197
+ this.log.debug(`Fetching builds for ${project}`);
198
+ return this.get(`projects/${project}/builds?${qs}`);
191
199
  } // Resolves when the build has finished and is no longer pending or
192
200
  // processing. By default, will time out if no update after 10 minutes.
193
201
 
@@ -196,49 +204,52 @@ class PercyClient {
196
204
  build,
197
205
  project,
198
206
  commit,
199
- progress,
200
- timeout = 600000,
207
+ timeout = 10 * 60 * 1000,
201
208
  interval = 1000
202
- }) {
209
+ }, onProgress) {
203
210
  if (commit && !project) {
204
- throw new Error('Missing project for commit');
211
+ throw new Error('Missing project path for commit');
205
212
  } else if (!commit && !build) {
206
213
  throw new Error('Missing build ID or commit SHA');
207
- } // get build data by id or project-commit combo
214
+ } else if (project) {
215
+ validateProjectPath(project);
216
+ }
208
217
 
218
+ let sha = commit && ((0, _utils.git)(`rev-parse ${commit}`) || commit);
209
219
 
210
- let getBuildData = async () => {
211
- let sha = commit && ((0, _git.git)(`rev-parse ${commit}`) || commit);
212
- let body = build ? await this.getBuild(build) : await this.getBuilds(project, {
213
- sha
214
- });
215
- let data = build ? body === null || body === void 0 ? void 0 : body.data : body === null || body === void 0 ? void 0 : body.data[0];
216
- return [data, data === null || data === void 0 ? void 0 : data.attributes.state];
217
- }; // recursively poll every second until the build finishes
220
+ let fetchData = async () => build ? (await this.getBuild(build)).data : (await this.getBuilds(project, {
221
+ sha
222
+ })).data[0];
218
223
 
224
+ this.log.debug(`Waiting for build ${build || `${project} (${commit})`}...`); // recursively poll every second until the build finishes
219
225
 
220
226
  return new Promise((resolve, reject) => async function poll(last, t) {
221
227
  try {
222
- let [data, state] = await getBuildData();
223
- let updated = JSON.stringify(data) !== JSON.stringify(last);
224
- let pending = !state || state === 'pending' || state === 'processing'; // new data recieved
228
+ let data = await fetchData();
229
+ let state = data === null || data === void 0 ? void 0 : data.attributes.state;
230
+ let pending = !state || state === 'pending' || state === 'processing';
231
+ let updated = JSON.stringify(data) !== JSON.stringify(last); // new data received
225
232
 
226
233
  if (updated) {
227
234
  t = Date.now(); // no new data within the timeout
228
235
  } else if (Date.now() - t >= timeout) {
229
236
  throw new Error('Timeout exceeded without an update');
230
- } // call progress after the first update
237
+ } // call progress every update after the first update
231
238
 
232
239
 
233
- if ((last || pending) && updated && progress) {
234
- progress(data);
240
+ if ((last || pending) && updated) {
241
+ onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
235
242
  } // not finished, poll again
236
243
 
237
244
 
238
245
  if (pending) {
239
246
  return setTimeout(poll, interval, data, t); // build finished
240
247
  } else {
241
- resolve(data);
248
+ // ensure progress is called at least once
249
+ if (!last) onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
250
+ resolve({
251
+ data
252
+ });
242
253
  }
243
254
  } catch (err) {
244
255
  reject(err);
@@ -249,70 +260,70 @@ class PercyClient {
249
260
  // created from `content` if one is not provided.
250
261
 
251
262
 
252
- async uploadResource({
263
+ async uploadResource(buildId, {
264
+ url,
253
265
  sha,
254
266
  filepath,
255
267
  content
256
- }) {
257
- if (!this.build.id) {
258
- throw new Error('This client instance has no active build');
259
- }
260
-
268
+ } = {}) {
269
+ validateBuildId(buildId);
270
+ this.log.debug(`Uploading resource: ${url}...`);
261
271
  content = filepath ? require('fs').readFileSync(filepath) : content;
262
- return this.post(`builds/${this.build.id}/resources`, {
272
+ return this.post(`builds/${buildId}/resources`, {
263
273
  data: {
264
274
  type: 'resources',
265
- id: sha || (0, _utils.sha256hash)(content),
275
+ id: sha || (0, _utils2.sha256hash)(content),
266
276
  attributes: {
267
- 'base64-content': (0, _utils.base64encode)(content)
277
+ 'base64-content': (0, _utils2.base64encode)(content)
268
278
  }
269
279
  }
270
280
  });
271
281
  } // Uploads resources to the active build concurrently, two at a time.
272
282
 
273
283
 
274
- async uploadResources(resources) {
275
- if (!this.build.id) {
276
- throw new Error('This client instance has no active build');
277
- }
278
-
279
- return (0, _utils.pool)(function* () {
284
+ async uploadResources(buildId, resources) {
285
+ validateBuildId(buildId);
286
+ this.log.debug(`Uploading resources for ${buildId}...`);
287
+ return (0, _utils2.pool)(function* () {
280
288
  for (let resource of resources) {
281
- yield this.uploadResource(resource);
289
+ yield this.uploadResource(buildId, resource);
282
290
  }
283
291
  }, this, 2);
284
292
  } // Creates a snapshot for the active build using the provided attributes.
285
293
 
286
294
 
287
- async createSnapshot({
295
+ async createSnapshot(buildId, {
288
296
  name,
289
297
  widths,
290
- minimumHeight,
298
+ minHeight,
291
299
  enableJavaScript,
292
300
  clientInfo,
293
301
  environmentInfo,
294
302
  resources = []
295
303
  } = {}) {
296
- if (!this.build.id) {
297
- throw new Error('This client instance has no active build');
298
- }
299
-
304
+ validateBuildId(buildId);
300
305
  this.addClientInfo(clientInfo);
301
306
  this.addEnvironmentInfo(environmentInfo);
302
- return this.post(`builds/${this.build.id}/snapshots`, {
307
+
308
+ if (!this.clientInfo.size || !this.environmentInfo.size) {
309
+ this.log.warn('Warning: Missing `clientInfo` and/or `environmentInfo` properties');
310
+ }
311
+
312
+ this.log.debug(`Creating snapshot: ${name}...`);
313
+ return this.post(`builds/${buildId}/snapshots`, {
303
314
  data: {
304
315
  type: 'snapshots',
305
316
  attributes: {
306
317
  name: name || null,
307
318
  widths: widths || null,
308
- 'minimum-height': minimumHeight || null,
319
+ 'minimum-height': minHeight || null,
309
320
  'enable-javascript': enableJavaScript || null
310
321
  },
311
322
  relationships: {
312
323
  resources: {
313
324
  data: resources.map(r => ({
314
325
  type: 'resources',
315
- id: r.sha || (0, _utils.sha256hash)(r.content),
326
+ id: r.sha || (0, _utils2.sha256hash)(r.content),
316
327
  attributes: {
317
328
  'resource-url': r.url || null,
318
329
  'is-root': r.root || null,
@@ -327,31 +338,34 @@ class PercyClient {
327
338
 
328
339
 
329
340
  async finalizeSnapshot(snapshotId) {
341
+ if (!snapshotId) throw new Error('Missing snapshot ID');
342
+ this.log.debug(`Finalizing snapshot ${snapshotId}...`);
330
343
  return this.post(`snapshots/${snapshotId}/finalize`);
331
344
  } // Convenience method for creating a snapshot for the active build, uploading
332
345
  // missing resources for the snapshot, and finalizing the snapshot.
333
346
 
334
347
 
335
- async sendSnapshot(options) {
336
- var _data$relationships, _data$relationships$m;
348
+ async sendSnapshot(buildId, options) {
349
+ var _snapshot$data$relati, _snapshot$data$relati2;
337
350
 
338
- let {
339
- data
340
- } = await this.createSnapshot(options);
341
- let missing = (_data$relationships = data.relationships) === null || _data$relationships === void 0 ? void 0 : (_data$relationships$m = _data$relationships['missing-resources']) === null || _data$relationships$m === void 0 ? void 0 : _data$relationships$m.data;
351
+ let snapshot = await this.createSnapshot(buildId, options);
352
+ 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;
342
353
 
343
- if (missing === null || missing === void 0 ? void 0 : missing.length) {
354
+ if (missing !== null && missing !== void 0 && missing.length) {
344
355
  let resources = options.resources.reduce((acc, r) => Object.assign(acc, {
345
356
  [r.sha]: r
346
357
  }), {});
347
- await this.uploadResources(missing.map(({
358
+ await this.uploadResources(buildId, missing.map(({
348
359
  id
349
360
  }) => resources[id]));
350
361
  }
351
362
 
352
- await this.finalizeSnapshot(data.id);
363
+ await this.finalizeSnapshot(snapshot.data.id);
364
+ return snapshot;
353
365
  }
354
366
 
355
367
  }
356
368
 
357
- exports.default = PercyClient;
369
+ exports.PercyClient = PercyClient;
370
+ var _default = PercyClient;
371
+ exports.default = _default;
package/dist/index.js CHANGED
@@ -3,6 +3,12 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ Object.defineProperty(exports, "PercyClient", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _client.PercyClient;
10
+ }
11
+ });
6
12
  Object.defineProperty(exports, "default", {
7
13
  enumerable: true,
8
14
  get: function () {
@@ -10,6 +16,8 @@ Object.defineProperty(exports, "default", {
10
16
  }
11
17
  });
12
18
 
13
- var _client = _interopRequireDefault(require("./client"));
19
+ var _client = _interopRequireWildcard(require("./client"));
20
+
21
+ function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
14
22
 
15
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
23
+ function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = exports.ProxyHttpsAgent = exports.ProxyHttpAgent = void 0;
7
+ exports.getProxy = getProxy;
8
+ exports.href = href;
9
+ exports.port = port;
10
+ exports.proxyAgentFor = proxyAgentFor;
11
+ exports.request = request;
12
+
13
+ var _net = _interopRequireDefault(require("net"));
14
+
15
+ var _tls = _interopRequireDefault(require("tls"));
16
+
17
+ var _http = _interopRequireDefault(require("http"));
18
+
19
+ var _https = _interopRequireDefault(require("https"));
20
+
21
+ var _logger = _interopRequireDefault(require("@percy/logger"));
22
+
23
+ var _utils = require("./utils");
24
+
25
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
26
+
27
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
28
+
29
+ const CRLF = '\r\n';
30
+ const STATUS_REG = /^HTTP\/1.[01] (\d*)/;
31
+ const RETRY_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN']; // Returns the port number of a URL object. Defaults to port 443 for https
32
+ // protocols or port 80 otherwise.
33
+
34
+ function port(options) {
35
+ if (options.port) return options.port;
36
+ return options.protocol === 'https:' ? 443 : 80;
37
+ }
38
+
39
+ function href(options) {
40
+ let {
41
+ protocol,
42
+ hostname,
43
+ path,
44
+ pathname,
45
+ search,
46
+ hash
47
+ } = options;
48
+ return `${protocol}//${hostname}:${port(options)}` + (path || `${pathname || ''}${search || ''}${hash || ''}`);
49
+ }
50
+
51
+ ;
52
+
53
+ function getProxy(options) {
54
+ let proxyUrl = options.protocol === 'https:' && (process.env.https_proxy || process.env.HTTPS_PROXY) || process.env.http_proxy || process.env.HTTP_PROXY;
55
+ let shouldProxy = !!proxyUrl && !(0, _utils.hostnameMatches)(process.env.no_proxy || process.env.NO_PROXY, href(options));
56
+
57
+ if (shouldProxy) {
58
+ proxyUrl = new URL(proxyUrl);
59
+ let isHttps = proxyUrl.protocol === 'https:';
60
+
61
+ if (!isHttps && proxyUrl.protocol !== 'http:') {
62
+ throw new Error(`Unsupported proxy protocol: ${proxyUrl.protocol}`);
63
+ }
64
+
65
+ let proxy = {
66
+ isHttps
67
+ };
68
+ proxy.auth = !!proxyUrl.username && 'Basic ' + (proxyUrl.password ? Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`) : Buffer.from(proxyUrl.username)).toString('base64');
69
+ proxy.host = proxyUrl.hostname;
70
+ proxy.port = port(proxyUrl);
71
+
72
+ proxy.connect = () => (isHttps ? _tls.default : _net.default).connect({
73
+ rejectUnauthorized: options.rejectUnauthorized,
74
+ host: proxy.host,
75
+ port: proxy.port
76
+ });
77
+
78
+ return proxy;
79
+ }
80
+ } // Proxified http agent
81
+
82
+
83
+ class ProxyHttpAgent extends _http.default.Agent {
84
+ constructor(...args) {
85
+ super(...args);
86
+
87
+ _defineProperty(this, "httpsAgent", new _https.default.Agent({
88
+ keepAlive: true
89
+ }));
90
+ }
91
+
92
+ addRequest(request, options) {
93
+ var _request$outputData;
94
+
95
+ let proxy = getProxy(options);
96
+ if (!proxy) return super.addRequest(request, options);
97
+ (0, _logger.default)('client:proxy').debug(`Proxying request: ${options.href}`); // modify the request for proxying
98
+
99
+ request.path = href(options);
100
+
101
+ if (proxy.auth) {
102
+ request.setHeader('Proxy-Authorization', proxy.auth);
103
+ } // regenerate headers since we just changed things
104
+
105
+
106
+ delete request._header;
107
+
108
+ request._implicitHeader();
109
+
110
+ if (((_request$outputData = request.outputData) === null || _request$outputData === void 0 ? void 0 : _request$outputData.length) > 0) {
111
+ let first = request.outputData[0].data;
112
+ let endOfHeaders = first.indexOf(CRLF.repeat(2)) + 4;
113
+ request.outputData[0].data = request._header + first.substring(endOfHeaders);
114
+ } // coerce the connection to the proxy
115
+
116
+
117
+ options.port = proxy.port;
118
+ options.host = proxy.host;
119
+ delete options.path;
120
+
121
+ if (proxy.isHttps) {
122
+ // use the underlying https agent to complete the connection
123
+ request.agent = this.httpsAgent;
124
+ return this.httpsAgent.addRequest(request, options);
125
+ } else {
126
+ return super.addRequest(request, options);
127
+ }
128
+ }
129
+
130
+ } // Proxified https agent
131
+
132
+
133
+ exports.ProxyHttpAgent = ProxyHttpAgent;
134
+
135
+ class ProxyHttpsAgent extends _https.default.Agent {
136
+ constructor(options) {
137
+ // default keep-alive
138
+ super({
139
+ keepAlive: true,
140
+ ...options
141
+ });
142
+ }
143
+
144
+ createConnection(options, callback) {
145
+ let proxy = getProxy(options);
146
+ if (!proxy) return super.createConnection(options, callback);
147
+ (0, _logger.default)('client:proxy').debug(`Proxying request: ${href(options)}`); // generate proxy connect message
148
+
149
+ let host = `${options.hostname}:${port(options)}`;
150
+ let connectMessage = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
151
+
152
+ if (proxy.auth) {
153
+ connectMessage.push(`Proxy-Authorization: ${proxy.auth}`);
154
+ }
155
+
156
+ connectMessage = connectMessage.join(CRLF);
157
+ connectMessage += CRLF.repeat(2); // start the proxy connection and setup listeners
158
+
159
+ let socket = proxy.connect();
160
+
161
+ let handleError = err => {
162
+ socket.destroy(err);
163
+ callback(err);
164
+ };
165
+
166
+ let handleClose = () => handleError(new Error('Connection closed while sending request to upstream proxy'));
167
+
168
+ let buffer = '';
169
+
170
+ let handleData = data => {
171
+ var _buffer$match;
172
+
173
+ buffer += data.toString(); // haven't received end of headers yet, keep buffering
174
+
175
+ if (!buffer.includes(CRLF.repeat(2))) return; // stop listening after end of headers
176
+
177
+ socket.off('data', handleData);
178
+
179
+ if (((_buffer$match = buffer.match(STATUS_REG)) === null || _buffer$match === void 0 ? void 0 : _buffer$match[1]) !== '200') {
180
+ return handleError(new Error('Error establishing proxy connection. ' + `Response from server was: ${buffer}`));
181
+ }
182
+
183
+ options.socket = socket;
184
+ options.servername = options.hostname; // callback not passed in so not to be added as a listener
185
+
186
+ callback(null, super.createConnection(options));
187
+ }; // send and handle the connect message
188
+
189
+
190
+ socket.on('error', handleError).on('close', handleClose).on('data', handleData).write(connectMessage);
191
+ }
192
+
193
+ }
194
+
195
+ exports.ProxyHttpsAgent = ProxyHttpsAgent;
196
+
197
+ function proxyAgentFor(url, options) {
198
+ let cache = proxyAgentFor.cache || (proxyAgentFor.cache = new Map());
199
+ let {
200
+ protocol,
201
+ hostname
202
+ } = new URL(url);
203
+ let cachekey = `${protocol}//${hostname}`;
204
+
205
+ if (!cache.has(cachekey)) {
206
+ cache.set(cachekey, protocol === 'https:' ? new ProxyHttpsAgent(options) : new ProxyHttpAgent(options));
207
+ }
208
+
209
+ return cache.get(cachekey);
210
+ } // Proxified request function that resolves with the response body when the request is successful
211
+ // and rejects when a non-successful response is received. The rejected error contains response data
212
+ // and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by
213
+ // default, and 404 errors may also be optionally retried. If a callback is provided, it is called
214
+ // with the parsed response body and response details. If the callback returns a value, that value
215
+ // will be returned in the final resolved promise instead of the response body.
216
+
217
+
218
+ function request(url, options = {}, callback) {
219
+ // accept `request(url, callback)`
220
+ if (typeof options === 'function') [options, callback] = [{}, options];
221
+ let {
222
+ body,
223
+ retries,
224
+ retryNotFound,
225
+ interval,
226
+ noProxy,
227
+ ...requestOptions
228
+ } = options; // allow bypassing proxied requests entirely
229
+
230
+ if (!noProxy) requestOptions.agent || (requestOptions.agent = proxyAgentFor(url)); // parse the requested URL into request options
231
+
232
+ let {
233
+ protocol,
234
+ hostname,
235
+ port,
236
+ pathname,
237
+ search,
238
+ hash
239
+ } = new URL(url);
240
+ return (0, _utils.retry)((resolve, reject, retry) => {
241
+ let handleError = error => {
242
+ if (handleError.handled) return;
243
+ handleError.handled = true;
244
+ let shouldRetry = error.response // maybe retry 404s and always retry 500s
245
+ ? retryNotFound && error.response.status === 404 || error.response.status >= 500 && error.response.status < 600 // retry specific error codes
246
+ : !!error.code && RETRY_ERROR_CODES.includes(error.code);
247
+ return shouldRetry ? retry(error) : reject(error);
248
+ };
249
+
250
+ let handleFinished = async (body, res) => {
251
+ let raw = body; // attempt to parse the body as json
252
+
253
+ try {
254
+ body = JSON.parse(body);
255
+ } catch (e) {}
256
+
257
+ try {
258
+ if (res.statusCode >= 200 && res.statusCode < 300) {
259
+ var _await$callback, _callback;
260
+
261
+ // resolve successful statuses after the callback
262
+ resolve((_await$callback = await ((_callback = callback) === null || _callback === void 0 ? void 0 : _callback(body, res))) !== null && _await$callback !== void 0 ? _await$callback : body);
263
+ } else {
264
+ var _body, _body$errors, _body$errors$find;
265
+
266
+ // use the first error detail or the status message
267
+ throw new Error(((_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) || `${res.statusCode} ${res.statusMessage || raw}`);
268
+ }
269
+ } catch (error) {
270
+ handleError(Object.assign(error, {
271
+ response: {
272
+ status: res.statusCode,
273
+ body
274
+ }
275
+ }));
276
+ }
277
+ };
278
+
279
+ let handleResponse = res => {
280
+ let body = '';
281
+ res.setEncoding('utf8');
282
+ res.on('data', chunk => body += chunk);
283
+ res.on('end', () => handleFinished(body, res));
284
+ res.on('error', handleError);
285
+ };
286
+
287
+ let req = (protocol === 'https:' ? _https.default : _http.default).request({ ...requestOptions,
288
+ path: pathname + search + hash,
289
+ protocol,
290
+ hostname,
291
+ port
292
+ });
293
+ req.on('response', handleResponse);
294
+ req.on('error', handleError);
295
+ req.end(body);
296
+ }, {
297
+ retries,
298
+ interval
299
+ });
300
+ }
301
+
302
+ var _default = request;
303
+ exports.default = _default;
package/dist/utils.js CHANGED
@@ -3,16 +3,14 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.sha256hash = sha256hash;
7
6
  exports.base64encode = base64encode;
7
+ exports.hostnameMatches = hostnameMatches;
8
8
  exports.pool = pool;
9
- exports.httpAgentFor = httpAgentFor;
10
- exports.request = request;
9
+ exports.retry = retry;
10
+ exports.sha256hash = sha256hash;
11
11
 
12
12
  var _crypto = _interopRequireDefault(require("crypto"));
13
13
 
14
- var _url = require("url");
15
-
16
14
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
15
 
18
16
  // Returns a sha256 hash of a string.
@@ -74,17 +72,13 @@ function pool(generator, context, concurrency) {
74
72
  function retry(fn, {
75
73
  retries = 5,
76
74
  interval = 50
77
- } = {}) {
75
+ }) {
78
76
  return new Promise((resolve, reject) => {
79
- // run the function, decrement retries
80
- let run = () => {
81
- fn(resolve, reject, retry);
82
- retries--;
83
- }; // wait an interval to try again or reject with the error
77
+ let run = () => fn(resolve, reject, retry); // wait an interval to try again or reject with the error
84
78
 
85
79
 
86
80
  let retry = err => {
87
- if (retries) {
81
+ if (retries-- > 0) {
88
82
  setTimeout(run, interval);
89
83
  } else {
90
84
  reject(err);
@@ -94,83 +88,35 @@ function retry(fn, {
94
88
 
95
89
  run();
96
90
  });
97
- } // Returns the appropriate http or https module for a given URL.
91
+ } // Returns true if the URL hostname matches any patterns
98
92
 
99
93
 
100
- function httpModuleFor(url) {
101
- return url.match(/^https:\/\//) ? require('https') : require('http');
102
- } // Returns the appropriate http or https Agent instance for a given URL.
94
+ function hostnameMatches(patterns, url) {
95
+ let subject = new URL(url);
96
+ /* istanbul ignore next: only strings are provided internally by the client proxy; core (which
97
+ * borrows this util) sometimes provides an array of patterns or undefined */
103
98
 
99
+ patterns = typeof patterns === 'string' ? patterns.split(/[\s,]+/) : [].concat(patterns);
104
100
 
105
- function httpAgentFor(url) {
106
- let {
107
- Agent
108
- } = httpModuleFor(url);
109
- return new Agent({
110
- keepAlive: true,
111
- maxSockets: 5
112
- });
113
- } // Returns a promise that resolves when the request is successful and rejects
114
- // when a non-successful response is received. The rejected error contains
115
- // response data and any received error details. Server 500 errors are retried
116
- // up to 5 times at 50ms intervals.
101
+ for (let pattern of patterns) {
102
+ if (pattern === '*') return true;
103
+ if (!pattern) continue; // parse pattern
117
104
 
105
+ let {
106
+ groups: rule
107
+ } = pattern.match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/); // missing a hostname or ports do not match
118
108
 
119
- function request(url, {
120
- body,
121
- ...options
122
- }) {
123
- let http = httpModuleFor(url);
124
- let {
125
- protocol,
126
- hostname,
127
- port,
128
- pathname,
129
- search
130
- } = new _url.URL(url);
131
- options = { ...options,
132
- protocol,
133
- hostname,
134
- port,
135
- path: pathname + search
136
- };
137
- return retry((resolve, reject, retry) => {
138
- http.request(options).on('response', res => {
139
- let status = res.statusCode;
140
- let raw = '';
141
- res.setEncoding('utf8');
142
- res.on('data', chunk => {
143
- raw += chunk;
144
- });
145
- res.on('end', () => {
146
- let body = raw; // attempt to parse json responses
147
-
148
- try {
149
- body = JSON.parse(raw);
150
- } catch (e) {} // success
151
-
152
-
153
- if (status >= 200 && status < 300) {
154
- resolve(body);
155
- } else {
156
- var _body, _body$errors, _body$errors$;
157
-
158
- // error
159
- let err = Object.assign(new Error(), {
160
- response: {
161
- status,
162
- body
163
- },
164
- message: ((_body = body) === null || _body === void 0 ? void 0 : (_body$errors = _body.errors) === null || _body$errors === void 0 ? void 0 : (_body$errors$ = _body$errors[0]) === null || _body$errors$ === void 0 ? void 0 : _body$errors$.detail) || `${status} ${res.statusMessage || raw}`
165
- }); // retry 500s
166
-
167
- if (status >= 500) {
168
- retry(err);
169
- } else {
170
- reject(err);
171
- }
172
- }
173
- });
174
- }).on('error', reject).end(body);
175
- });
109
+ if (!rule.hostname || rule.port && rule.port !== subject.port) {
110
+ continue;
111
+ } // wildcards are treated the same as leading dots
112
+
113
+
114
+ rule.hostname = rule.hostname.replace(/^\*/, ''); // hostnames are equal or end with a wildcard rule
115
+
116
+ if (rule.hostname === subject.hostname || rule.hostname.startsWith('.') && subject.hostname.endsWith(rule.hostname)) {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ return false;
176
122
  }
package/package.json CHANGED
@@ -1,28 +1,35 @@
1
1
  {
2
2
  "name": "@percy/client",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.73",
4
4
  "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/percy/cli",
8
+ "directory": "packages/client"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
5
13
  "main": "dist/index.js",
6
14
  "files": [
7
- "dist"
15
+ "dist",
16
+ "test/helpers.js"
8
17
  ],
18
+ "engines": {
19
+ "node": ">=12"
20
+ },
9
21
  "scripts": {
10
- "build": "babel --root-mode upward src --out-dir dist",
22
+ "build": "node ../../scripts/build",
11
23
  "lint": "eslint --ignore-path ../../.gitignore .",
12
- "test": "cross-env NODE_ENV=test mocha",
13
- "test:coverage": "nyc yarn test"
24
+ "test": "node ../../scripts/test",
25
+ "test:coverage": "yarn test --coverage"
14
26
  },
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "mocha": {
19
- "require": "../../scripts/babel-register"
27
+ "dependencies": {
28
+ "@percy/env": "1.0.0-beta.73",
29
+ "@percy/logger": "1.0.0-beta.73"
20
30
  },
21
31
  "devDependencies": {
22
32
  "mock-require": "^3.0.3"
23
33
  },
24
- "dependencies": {
25
- "@percy/env": "^1.0.0-beta.7"
26
- },
27
- "gitHead": "5be796ec8f17958e93ada0b634899b945c9b0d60"
34
+ "gitHead": "aa8160e02bea3e04ab1d3605762f89fbe79605d4"
28
35
  }
@@ -0,0 +1,81 @@
1
+ const nock = require('nock');
2
+
3
+ const DEFAULT_REPLIES = {
4
+ '/builds': () => [201, {
5
+ data: {
6
+ id: '123',
7
+ attributes: {
8
+ 'build-number': 1,
9
+ 'web-url': 'https://percy.io/test/test/123'
10
+ }
11
+ }
12
+ }],
13
+
14
+ '/builds/123/snapshots': ({ body }) => [201, {
15
+ data: {
16
+ id: '4567',
17
+ attributes: body.attributes,
18
+ relationships: {
19
+ 'missing-resources': {
20
+ data: body.data.relationships.resources
21
+ .data.map(({ id }) => ({ id }))
22
+ }
23
+ }
24
+ }
25
+ }]
26
+ };
27
+
28
+ const mockAPI = {
29
+ nock: null,
30
+ requests: null,
31
+ replies: null,
32
+
33
+ start(delay = 0) {
34
+ nock.cleanAll();
35
+ nock.disableNetConnect();
36
+ nock.enableNetConnect('storage.googleapis.com|localhost|127.0.0.1');
37
+
38
+ let n = this.nock = nock('https://percy.io/api/v1').persist();
39
+ let requests = this.requests = {};
40
+ let replies = this.replies = {};
41
+
42
+ function intercept(_, body) {
43
+ let { path, headers, method } = this.req;
44
+
45
+ try { body = JSON.parse(body); } catch {}
46
+ path = path.replace('/api/v1', '');
47
+
48
+ let req = { body, headers, method };
49
+ let reply = replies[path] && (
50
+ replies[path].length > 1
51
+ ? replies[path].shift()
52
+ : replies[path][0]
53
+ );
54
+
55
+ requests[path] = requests[path] || [];
56
+ requests[path].push(req);
57
+
58
+ return reply ? reply(req) : (
59
+ DEFAULT_REPLIES[path]
60
+ ? DEFAULT_REPLIES[path](req)
61
+ : [200]
62
+ );
63
+ }
64
+
65
+ n.get(/.*/).delay(delay).reply(intercept);
66
+ n.post(/.*/).delay(delay).reply(intercept);
67
+ },
68
+
69
+ reply(path, handler) {
70
+ this.replies[path] = this.replies[path] || [];
71
+ this.replies[path].push(handler);
72
+ return this;
73
+ },
74
+
75
+ cleanAll() {
76
+ nock.cleanAll();
77
+ return this;
78
+ }
79
+ };
80
+
81
+ module.exports = mockAPI;