@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 +91 -28
- package/dist/client.js +123 -132
- package/dist/index.js +1 -15
- package/dist/proxy.js +205 -0
- package/dist/utils.js +126 -98
- package/package.json +26 -16
- package/test/helpers.js +152 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
+
## Usage
|
|
10
16
|
|
|
11
17
|
``` js
|
|
12
|
-
import PercyClient from '@percy/client'
|
|
18
|
+
import PercyClient from '@percy/client'
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
const client = new PercyClient({ token: 'abcdef123456' })
|
|
20
|
+
const client = new PercyClient(options)
|
|
16
21
|
```
|
|
17
22
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
await client.waitForBuild({
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
|
|
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
|
-
|
|
21
|
+
function validateProjectPath(path) {
|
|
22
|
+
if (!path) throw new Error('Missing project path');
|
|
11
23
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
apiUrl =
|
|
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
|
-
|
|
50
|
-
this.clientInfo.
|
|
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
|
-
|
|
57
|
-
this.environmentInfo.
|
|
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(`${
|
|
64
|
-
let environment = this.environmentInfo.concat(
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
this.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
194
|
+
} else if (project) {
|
|
195
|
+
validateProjectPath(project);
|
|
196
|
+
}
|
|
208
197
|
|
|
198
|
+
let sha = commit && (git(`rev-parse ${commit}`) || commit);
|
|
209
199
|
|
|
210
|
-
let
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
223
|
-
let
|
|
224
|
-
let pending = !state || state === 'pending' || state === 'processing';
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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 ||
|
|
255
|
+
id: sha || sha256hash(content),
|
|
266
256
|
attributes: {
|
|
267
|
-
'base64-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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
328
|
+
async sendSnapshot(buildId, options) {
|
|
329
|
+
var _snapshot$data$relati, _snapshot$data$relati2;
|
|
337
330
|
|
|
338
|
-
let
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
} //
|
|
98
|
-
|
|
89
|
+
} // Used by the request util when retrying specific errors
|
|
99
90
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
141
|
+
headers,
|
|
142
|
+
port
|
|
143
|
+
});
|
|
137
144
|
return retry((resolve, reject, retry) => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"
|
|
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
|
-
"
|
|
19
|
-
"
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=14"
|
|
20
15
|
},
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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": "
|
|
34
|
+
"@percy/env": "1.0.1",
|
|
35
|
+
"@percy/logger": "1.0.1"
|
|
26
36
|
},
|
|
27
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "38917e6027299d6cd86008e2ccd005d90bbf89c0"
|
|
28
38
|
}
|
package/test/helpers.js
ADDED
|
@@ -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;
|