@percy/client 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/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
- minHeight,
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
@@ -1,68 +1,76 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.default = void 0;
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
7
19
 
8
- var _env = _interopRequireDefault(require("@percy/env"));
9
20
 
10
- var _git = require("@percy/env/dist/git");
21
+ function validateProjectPath(path) {
22
+ if (!path) throw new Error('Missing project path');
11
23
 
12
- var _package = _interopRequireDefault(require("../package.json"));
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.
13
30
 
14
- var _utils = require("./utils");
15
31
 
16
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
32
+ export class PercyClient {
33
+ log = logger('client');
34
+ env = new PercyEnv(process.env);
35
+ clientInfo = new Set();
36
+ environmentInfo = new Set();
17
37
 
18
- // PercyClient is used to communicate with the Percy API to create and finalize
19
- // builds and snapshot. Uses @percy/env to collect environment information used
20
- // during build creation.
21
- class PercyClient {
22
38
  constructor({
23
39
  // read or write token, defaults to PERCY_TOKEN environment variable
24
40
  token,
25
41
  // initial user agent info
26
- clientInfo = '',
27
- environmentInfo = '',
28
- // versioned percy api url
29
- apiUrl = 'https://percy.io/api/v1'
42
+ clientInfo,
43
+ environmentInfo,
44
+ // versioned api url
45
+ apiUrl = PERCY_CLIENT_API_URL
30
46
  } = {}) {
31
47
  Object.assign(this, {
32
48
  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
- }
49
+ apiUrl
44
50
  });
51
+ this.addClientInfo(clientInfo);
52
+ this.addEnvironmentInfo(environmentInfo);
45
53
  } // Adds additional unique client info.
46
54
 
47
55
 
48
56
  addClientInfo(info) {
49
- if (info && this.clientInfo.indexOf(info) === -1) {
50
- this.clientInfo.push(info);
57
+ for (let i of [].concat(info)) {
58
+ if (i) this.clientInfo.add(i);
51
59
  }
52
60
  } // Adds additional unique environment info.
53
61
 
54
62
 
55
63
  addEnvironmentInfo(info) {
56
- if (info && this.environmentInfo.indexOf(info) === -1) {
57
- this.environmentInfo.push(info);
64
+ for (let i of [].concat(info)) {
65
+ if (i) this.environmentInfo.add(i);
58
66
  }
59
67
  } // Stringifies client and environment info.
60
68
 
61
69
 
62
70
  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})`;
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('; ')})`;
66
74
  } // Checks for a Percy token and returns it.
67
75
 
68
76
 
@@ -84,35 +92,20 @@ class PercyClient {
84
92
 
85
93
 
86
94
  get(path) {
87
- return (0, _utils.request)(`${this.apiUrl}/${path}`, {
88
- method: 'GET',
89
- agent: this.httpAgent,
90
- headers: this.headers()
95
+ return request(`${this.apiUrl}/${path}`, {
96
+ headers: this.headers(),
97
+ method: 'GET'
91
98
  });
92
99
  } // Performs a POST request to a JSON API endpoint with appropriate headers.
93
100
 
94
101
 
95
102
  post(path, body = {}) {
96
- return (0, _utils.request)(`${this.apiUrl}/${path}`, {
97
- method: 'POST',
98
- agent: this.httpAgent,
99
- body: JSON.stringify(body),
103
+ return request(`${this.apiUrl}/${path}`, {
100
104
  headers: this.headers({
101
105
  'Content-Type': 'application/vnd.api+json'
102
- })
103
- });
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
- }
106
+ }),
107
+ method: 'POST',
108
+ body
116
109
  });
117
110
  } // Creates a build with optional build resources. Only one build can be
118
111
  // created at a time per instance so snapshots and build finalization can be
@@ -122,11 +115,8 @@ class PercyClient {
122
115
  async createBuild({
123
116
  resources = []
124
117
  } = {}) {
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', {
118
+ this.log.debug('Creating a new build...');
119
+ return this.post('builds', {
130
120
  data: {
131
121
  type: 'builds',
132
122
  attributes: {
@@ -149,7 +139,7 @@ class PercyClient {
149
139
  resources: {
150
140
  data: resources.map(r => ({
151
141
  type: 'resources',
152
- id: r.sha || (0, _utils.sha256hash)(r.content),
142
+ id: r.sha || sha256hash(r.content),
153
143
  attributes: {
154
144
  'resource-url': r.url,
155
145
  'is-root': r.root || null,
@@ -160,34 +150,32 @@ class PercyClient {
160
150
  }
161
151
  }
162
152
  });
163
- this.setBuildData(body === null || body === void 0 ? void 0 : body.data);
164
- return body;
165
153
  } // Finalizes the active build. When `all` is true, `all-shards=true` is
166
154
  // added as a query param so the API finalizes all other build shards.
167
155
 
168
156
 
169
- async finalizeBuild({
157
+ async finalizeBuild(buildId, {
170
158
  all = false
171
159
  } = {}) {
172
- if (!this.build.id) {
173
- throw new Error('This client instance has no active build');
174
- }
175
-
160
+ validateBuildId(buildId);
176
161
  let qs = all ? 'all-shards=true' : '';
177
- let body = await this.post(`builds/${this.build.id}/finalize?${qs}`);
178
- this.setBuildData();
179
- return body;
162
+ this.log.debug(`Finalizing build ${buildId}...`);
163
+ return this.post(`builds/${buildId}/finalize?${qs}`);
180
164
  } // Retrieves build data by id. Requires a read access token.
181
165
 
182
166
 
183
167
  async getBuild(buildId) {
168
+ validateBuildId(buildId);
169
+ this.log.debug(`Get build ${buildId}`);
184
170
  return this.get(`builds/${buildId}`);
185
171
  } // Retrieves project builds optionally filtered. Requires a read access token.
186
172
 
187
173
 
188
- async getBuilds(projectSlug, filters = {}) {
174
+ async getBuilds(project, filters = {}) {
175
+ validateProjectPath(project);
189
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('&');
190
- return this.get(`projects/${projectSlug}/builds?${qs}`);
177
+ this.log.debug(`Fetching builds for ${project}`);
178
+ return this.get(`projects/${project}/builds?${qs}`);
191
179
  } // Resolves when the build has finished and is no longer pending or
192
180
  // processing. By default, will time out if no update after 10 minutes.
193
181
 
@@ -196,49 +184,52 @@ class PercyClient {
196
184
  build,
197
185
  project,
198
186
  commit,
199
- progress,
200
- timeout = 600000,
187
+ timeout = 10 * 60 * 1000,
201
188
  interval = 1000
202
- }) {
189
+ }, onProgress) {
203
190
  if (commit && !project) {
204
- throw new Error('Missing project for commit');
191
+ throw new Error('Missing project path for commit');
205
192
  } else if (!commit && !build) {
206
193
  throw new Error('Missing build ID or commit SHA');
207
- } // get build data by id or project-commit combo
194
+ } else if (project) {
195
+ validateProjectPath(project);
196
+ }
208
197
 
198
+ let sha = commit && (git(`rev-parse ${commit}`) || commit);
209
199
 
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
200
+ let fetchData = async () => build ? (await this.getBuild(build)).data : (await this.getBuilds(project, {
201
+ sha
202
+ })).data[0];
218
203
 
204
+ this.log.debug(`Waiting for build ${build || `${project} (${commit})`}...`); // recursively poll every second until the build finishes
219
205
 
220
206
  return new Promise((resolve, reject) => async function poll(last, t) {
221
207
  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
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
225
212
 
226
213
  if (updated) {
227
214
  t = Date.now(); // no new data within the timeout
228
215
  } else if (Date.now() - t >= timeout) {
229
216
  throw new Error('Timeout exceeded without an update');
230
- } // call progress after the first update
217
+ } // call progress every update after the first update
231
218
 
232
219
 
233
- if ((last || pending) && updated && progress) {
234
- progress(data);
220
+ if ((last || pending) && updated) {
221
+ onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
235
222
  } // not finished, poll again
236
223
 
237
224
 
238
225
  if (pending) {
239
226
  return setTimeout(poll, interval, data, t); // build finished
240
227
  } else {
241
- resolve(data);
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
+ });
242
233
  }
243
234
  } catch (err) {
244
235
  reject(err);
@@ -249,42 +240,39 @@ class PercyClient {
249
240
  // created from `content` if one is not provided.
250
241
 
251
242
 
252
- async uploadResource({
243
+ async uploadResource(buildId, {
244
+ url,
253
245
  sha,
254
246
  filepath,
255
247
  content
256
- }) {
257
- if (!this.build.id) {
258
- throw new Error('This client instance has no active build');
259
- }
260
-
261
- content = filepath ? require('fs').readFileSync(filepath) : content;
262
- return this.post(`builds/${this.build.id}/resources`, {
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`, {
263
253
  data: {
264
254
  type: 'resources',
265
- id: sha || (0, _utils.sha256hash)(content),
255
+ id: sha || sha256hash(content),
266
256
  attributes: {
267
- 'base64-content': (0, _utils.base64encode)(content)
257
+ 'base64-content': base64encode(content)
268
258
  }
269
259
  }
270
260
  });
271
261
  } // Uploads resources to the active build concurrently, two at a time.
272
262
 
273
263
 
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* () {
264
+ async uploadResources(buildId, resources) {
265
+ validateBuildId(buildId);
266
+ this.log.debug(`Uploading resources for ${buildId}...`);
267
+ return pool(function* () {
280
268
  for (let resource of resources) {
281
- yield this.uploadResource(resource);
269
+ yield this.uploadResource(buildId, resource);
282
270
  }
283
271
  }, this, 2);
284
272
  } // Creates a snapshot for the active build using the provided attributes.
285
273
 
286
274
 
287
- async createSnapshot({
275
+ async createSnapshot(buildId, {
288
276
  name,
289
277
  widths,
290
278
  minHeight,
@@ -293,13 +281,16 @@ class PercyClient {
293
281
  environmentInfo,
294
282
  resources = []
295
283
  } = {}) {
296
- if (!this.build.id) {
297
- throw new Error('This client instance has no active build');
298
- }
299
-
284
+ validateBuildId(buildId);
300
285
  this.addClientInfo(clientInfo);
301
286
  this.addEnvironmentInfo(environmentInfo);
302
- return this.post(`builds/${this.build.id}/snapshots`, {
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`, {
303
294
  data: {
304
295
  type: 'snapshots',
305
296
  attributes: {
@@ -312,7 +303,7 @@ class PercyClient {
312
303
  resources: {
313
304
  data: resources.map(r => ({
314
305
  type: 'resources',
315
- id: r.sha || (0, _utils.sha256hash)(r.content),
306
+ id: r.sha || sha256hash(r.content),
316
307
  attributes: {
317
308
  'resource-url': r.url || null,
318
309
  'is-root': r.root || null,
@@ -327,31 +318,31 @@ class PercyClient {
327
318
 
328
319
 
329
320
  async finalizeSnapshot(snapshotId) {
321
+ if (!snapshotId) throw new Error('Missing snapshot ID');
322
+ this.log.debug(`Finalizing snapshot ${snapshotId}...`);
330
323
  return this.post(`snapshots/${snapshotId}/finalize`);
331
324
  } // Convenience method for creating a snapshot for the active build, uploading
332
325
  // missing resources for the snapshot, and finalizing the snapshot.
333
326
 
334
327
 
335
- async sendSnapshot(options) {
336
- var _data$relationships, _data$relationships$m;
328
+ async sendSnapshot(buildId, options) {
329
+ var _snapshot$data$relati, _snapshot$data$relati2;
337
330
 
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;
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;
342
333
 
343
- if (missing === null || missing === void 0 ? void 0 : missing.length) {
334
+ if (missing !== null && missing !== void 0 && missing.length) {
344
335
  let resources = options.resources.reduce((acc, r) => Object.assign(acc, {
345
336
  [r.sha]: r
346
337
  }), {});
347
- await this.uploadResources(missing.map(({
338
+ await this.uploadResources(buildId, missing.map(({
348
339
  id
349
340
  }) => resources[id]));
350
341
  }
351
342
 
352
- await this.finalizeSnapshot(data.id);
343
+ await this.finalizeSnapshot(snapshot.data.id);
344
+ return snapshot;
353
345
  }
354
346
 
355
347
  }
356
-
357
- exports.default = PercyClient;
348
+ export default PercyClient;
package/dist/index.js CHANGED
@@ -1,15 +1 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- Object.defineProperty(exports, "default", {
7
- enumerable: true,
8
- get: function () {
9
- return _client.default;
10
- }
11
- });
12
-
13
- var _client = _interopRequireDefault(require("./client"));
14
-
15
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
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 CHANGED
@@ -1,34 +1,31 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.sha256hash = sha256hash;
7
- exports.base64encode = base64encode;
8
- exports.pool = pool;
9
- exports.httpAgentFor = httpAgentFor;
10
- exports.request = request;
11
-
12
- var _crypto = _interopRequireDefault(require("crypto"));
13
-
14
- var _url = require("url");
15
-
16
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
-
18
- // Returns a sha256 hash of a string.
19
- function sha256hash(content) {
20
- return _crypto.default.createHash('sha256').update(content, 'utf-8').digest('hex');
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');
21
9
  } // Returns a base64 encoding of a string or buffer.
22
10
 
23
-
24
- function base64encode(content) {
11
+ export function base64encode(content) {
25
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);
26
24
  } // Creates a concurrent pool of promises created by the given generator.
27
25
  // Resolves when the generator's final promise resolves and rejects when any
28
26
  // generated promise rejects.
29
27
 
30
-
31
- function pool(generator, context, concurrency) {
28
+ export function pool(generator, context, concurrency) {
32
29
  return new Promise((resolve, reject) => {
33
30
  let iterator = generator.call(context);
34
31
  let queue = 0;
@@ -70,21 +67,16 @@ function pool(generator, context, concurrency) {
70
67
  // are exhausted, at which point the promise will reject with the last error
71
68
  // passed to `retry`.
72
69
 
73
-
74
- function retry(fn, {
70
+ export function retry(fn, {
75
71
  retries = 5,
76
72
  interval = 50
77
- } = {}) {
73
+ }) {
78
74
  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
75
+ let run = () => fn(resolve, reject, retry); // wait an interval to try again or reject with the error
84
76
 
85
77
 
86
78
  let retry = err => {
87
- if (retries) {
79
+ if (retries-- > 0) {
88
80
  setTimeout(run, interval);
89
81
  } else {
90
82
  reject(err);
@@ -94,83 +86,119 @@ function retry(fn, {
94
86
 
95
87
  run();
96
88
  });
97
- } // Returns the appropriate http or https module for a given URL.
98
-
89
+ } // Used by the request util when retrying specific errors
99
90
 
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.
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.
103
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
104
101
 
105
- function httpAgentFor(url) {
106
102
  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.
117
-
118
-
119
- function request(url, {
120
- body,
121
- ...options
122
- }) {
123
- let http = httpModuleFor(url);
103
+ body,
104
+ headers,
105
+ retries,
106
+ retryNotFound,
107
+ interval,
108
+ noProxy,
109
+ ...requestOptions
110
+ } = options;
124
111
  let {
125
112
  protocol,
126
113
  hostname,
127
114
  port,
128
115
  pathname,
129
- search
130
- } = new _url.URL(url);
131
- options = { ...options,
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,
132
139
  protocol,
133
140
  hostname,
134
- port,
135
- path: pathname + search
136
- };
141
+ headers,
142
+ port
143
+ });
137
144
  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);
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);
155
169
  } 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
- }
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}`);
172
174
  }
173
- });
174
- }).on('error', reject).end(body);
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
175
202
  });
176
- }
203
+ }
204
+ export { hostnameMatches, ProxyHttpAgent, ProxyHttpsAgent, proxyAgentFor } from './proxy.js';
package/package.json CHANGED
@@ -1,28 +1,38 @@
1
1
  {
2
2
  "name": "@percy/client",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.1",
4
4
  "license": "MIT",
5
- "main": "dist/index.js",
6
- "files": [
7
- "dist"
8
- ],
9
- "scripts": {
10
- "build": "babel --root-mode upward src --out-dir dist",
11
- "lint": "eslint --ignore-path ../../.gitignore .",
12
- "test": "cross-env NODE_ENV=test mocha",
13
- "test:coverage": "nyc yarn test"
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/percy/cli",
8
+ "directory": "packages/client"
14
9
  },
15
10
  "publishConfig": {
16
11
  "access": "public"
17
12
  },
18
- "mocha": {
19
- "require": "../../scripts/babel-register"
13
+ "engines": {
14
+ "node": ">=14"
20
15
  },
21
- "devDependencies": {
22
- "mock-require": "^3.0.3"
16
+ "files": [
17
+ "dist",
18
+ "test/helpers.js"
19
+ ],
20
+ "main": "./dist/index.js",
21
+ "type": "module",
22
+ "exports": {
23
+ ".": "./dist/index.js",
24
+ "./utils": "./dist/utils.js",
25
+ "./test/helpers": "./test/helpers.js"
26
+ },
27
+ "scripts": {
28
+ "build": "node ../../scripts/build",
29
+ "lint": "eslint --ignore-path ../../.gitignore .",
30
+ "test": "node ../../scripts/test",
31
+ "test:coverage": "yarn test --coverage"
23
32
  },
24
33
  "dependencies": {
25
- "@percy/env": "^1.0.0-beta.8"
34
+ "@percy/env": "1.0.1",
35
+ "@percy/logger": "1.0.1"
26
36
  },
27
- "gitHead": "6015850e7c20c130d625fcb327b10d7513b35707"
37
+ "gitHead": "38917e6027299d6cd86008e2ccd005d90bbf89c0"
28
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;