@percy/core 1.0.0-beta.7 → 1.0.0-beta.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -67
- package/dist/browser.js +363 -0
- package/dist/config.js +314 -25
- package/dist/discovery.js +130 -0
- package/dist/index.js +9 -23
- package/dist/install.js +173 -0
- package/dist/network.js +365 -0
- package/dist/page.js +293 -0
- package/dist/percy.js +298 -289
- package/dist/queue.js +146 -53
- package/dist/server.js +176 -63
- package/dist/session.js +140 -0
- package/dist/snapshot.js +279 -0
- package/dist/utils.js +168 -0
- package/package.json +27 -26
- package/post-install.js +23 -0
- package/test/helpers/server.js +22 -0
- package/types/index.d.ts +69 -39
- package/dist/discoverer.js +0 -368
- package/dist/percy-css.js +0 -33
- package/dist/utils/assert.js +0 -50
- package/dist/utils/bytes.js +0 -24
- package/dist/utils/idle.js +0 -15
- package/dist/utils/install-browser.js +0 -76
- package/dist/utils/resources.js +0 -75
- package/dist/utils/url.js +0 -64
package/README.md
CHANGED
|
@@ -5,32 +5,67 @@ assets, uploading snapshots, and finalizing builds. Uses `@percy/client` for API
|
|
|
5
5
|
Puppeteer browser for asset discovery, and starts a local API server for posting snapshots from
|
|
6
6
|
other processes.
|
|
7
7
|
|
|
8
|
+
- [Usage](#usage)
|
|
9
|
+
- [`#start()`](#start)
|
|
10
|
+
- [`#stop()`](#stopforce)
|
|
11
|
+
- [`#idle()`](#idle)
|
|
12
|
+
- [`#snapshot()`](#snapshotoptions)
|
|
13
|
+
- [Advanced](#advanced)
|
|
14
|
+
- [Download discovery browser on install](#download-discovery-browser-on-install)
|
|
15
|
+
- [Skipping discovery browser download](#skipping-discovery-browser-download)
|
|
16
|
+
|
|
8
17
|
## Usage
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
A `Percy` class instance can manage a Percy build, take page snapshots, and perform snapshot asset
|
|
20
|
+
discovery. It also hosts a local API server for Percy SDKs to communicate with.
|
|
12
21
|
|
|
13
22
|
``` js
|
|
14
23
|
import Percy from '@percy/core'
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
concurrency: 5, // concurrency of the #capture() method
|
|
22
|
-
snapshot: {}, // global snapshot options (see snapshots section)
|
|
23
|
-
discovery: { // asset discovery options
|
|
24
|
-
allowedHostnames: [], // list of hostnames allowed to capture from
|
|
25
|
-
networkIdleTimeout: 100, // how long before network is considered idle
|
|
26
|
-
disableAssetCache: false, // disable discovered asset caching
|
|
27
|
-
concurrency: 5, // asset discovery concurrency
|
|
28
|
-
launchOptions: {} // browser launch options
|
|
29
|
-
},
|
|
30
|
-
...config // additional config options accessible by SDKs
|
|
31
|
-
})
|
|
25
|
+
// create a new instance
|
|
26
|
+
const percy = new Percy(percyOptions)
|
|
27
|
+
|
|
28
|
+
// create a new instance and start it
|
|
29
|
+
const percy = await Percy.start(percyOptions)
|
|
32
30
|
```
|
|
33
31
|
|
|
32
|
+
#### Options
|
|
33
|
+
|
|
34
|
+
- `token` — Your project's `PERCY_TOKEN` (**default** `process.env.PERCY_TOKEN`)
|
|
35
|
+
- `loglevel` — Logger level, one of `"info"`, `"warn"`, `"error"`, `"debug"` (**default** `"info"`)
|
|
36
|
+
- `server` — Controls whether an API server is created (**default** `true`)
|
|
37
|
+
- `port` — API server port (**default** `5338`)
|
|
38
|
+
- `clientInfo` — Client info sent to Percy via a user-agent string
|
|
39
|
+
- `environmentInfo` — Environment info also sent with the user-agent string
|
|
40
|
+
- `deferUploads` — Defer creating a build and uploading snapshots until later
|
|
41
|
+
- `skipUploads` — Skip creating a build and uploading snapshots altogether
|
|
42
|
+
|
|
43
|
+
The following options can also be defined within a Percy config file
|
|
44
|
+
|
|
45
|
+
- `snapshot` — Snapshot options applied to each snapshot
|
|
46
|
+
- `widths` — Widths to take screenshots at (**default** `[375, 1280]`)
|
|
47
|
+
- `minHeight` — Minimum screenshot height (**default** `1024`)
|
|
48
|
+
- `percyCSS` — Percy specific CSS to inject into the snapshot
|
|
49
|
+
- `enableJavaScript` — Enable JavaScript for screenshots (**default** `false`)
|
|
50
|
+
- `discovery` — Asset discovery options
|
|
51
|
+
- `allowedHostnames` — Array of allowed hostnames to capture assets from
|
|
52
|
+
- `requestHeaders` — Request headers used when discovering snapshot assets
|
|
53
|
+
- `authorization` — Basic auth `username` and `password` for protected snapshot assets
|
|
54
|
+
- `disableCache` — Disable asset caching (**default** `false`)
|
|
55
|
+
- `userAgent` — Custom user-agent string used when requesting assets
|
|
56
|
+
- `cookies` — Browser cookies to use when requesting assets
|
|
57
|
+
- `networkIdleTimeout` — Milliseconds to wait for the network to idle (**default** `100`)
|
|
58
|
+
- `concurrency` — Asset discovery concerrency (**default** `5`)
|
|
59
|
+
- `launchOptions` — Asset discovery browser launch options
|
|
60
|
+
- `executable` — Browser executable path (**default** `process.env.PERCY_BROWSER_EXECUTABLE`)
|
|
61
|
+
- `timeout` — Discovery launch timeout, in milliseconds (**default** `30000`)
|
|
62
|
+
- `args` — Additional browser process arguments
|
|
63
|
+
- `headless` — Runs the browser headlessy (**default** `true`)
|
|
64
|
+
|
|
65
|
+
Additional Percy config file options are also allowed and will override any options defined by a
|
|
66
|
+
local config file. These config file options are also made available to SDKs via the local API
|
|
67
|
+
health check endpoint.
|
|
68
|
+
|
|
34
69
|
### `#start()`
|
|
35
70
|
|
|
36
71
|
Starting a `Percy` instance will start a local API server, start the asset discovery browser, and
|
|
@@ -39,81 +74,137 @@ create a new Percy build. If an asset discovery browser is not found, one will b
|
|
|
39
74
|
``` js
|
|
40
75
|
await percy.start()
|
|
41
76
|
// [percy] Percy has started!
|
|
42
|
-
// [percy] Created build #1: https://percy.io/org/project/123
|
|
43
77
|
```
|
|
44
78
|
|
|
45
79
|
#### API Server
|
|
46
80
|
|
|
47
81
|
Starting a `Percy` instance will start a local API server unless `server` is `false`. The server can
|
|
48
|
-
be found at `http://localhost:5338/` or at the provided `port` number.
|
|
49
|
-
JSON body with the `application/json` content-type.
|
|
82
|
+
be found at `http://localhost:5338/` or at the provided `port` number.
|
|
50
83
|
|
|
51
84
|
- GET `/percy/healthcheck` – Responds with information about the running instance
|
|
52
85
|
- GET `/percy/dom.js` – Responds with the [`@percy/dom`](./packages/dom) library
|
|
86
|
+
- GET `/percy/idle` - Responds when the running instance is [idle](#idle)
|
|
53
87
|
- POST `/percy/snapshot` – Calls [`#snapshot()`](#snapshotoptions) with provided snapshot options
|
|
54
|
-
- POST `/percy/stop` - Remotely [stops](#
|
|
88
|
+
- POST `/percy/stop` - Remotely [stops](#stopforce) the running `Percy` instance
|
|
55
89
|
|
|
56
|
-
### `#
|
|
90
|
+
### `#stop([force])`
|
|
57
91
|
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
Stopping a `Percy` instance will wait for any pending snapshots, close the asset discovery browser,
|
|
93
|
+
close the local API server, and finalize the current Percy build. When uploads are deferred,
|
|
94
|
+
stopping the instance will also trigger processing of the upload queue. When `force` is `true`,
|
|
95
|
+
queues are cleared and closed to prevent queued snapshots from running.
|
|
60
96
|
|
|
61
97
|
``` js
|
|
62
|
-
|
|
63
|
-
percy
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
})
|
|
98
|
+
await percy.stop()
|
|
99
|
+
// [percy] Processing 3 snapshots...
|
|
100
|
+
// [percy] Snapshot taken: Snapshot one
|
|
101
|
+
// [percy] Snapshot taken: Snapshot two
|
|
102
|
+
// [percy] Snapshot taken: Snapshot three
|
|
103
|
+
// [percy] Uploading 3 snapshots...
|
|
104
|
+
// [percy] Finalized build #1: https://percy.io/org/project/123
|
|
105
|
+
|
|
106
|
+
await percy.stop(true)
|
|
107
|
+
// [percy] Stopping percy...
|
|
108
|
+
// [percy] Finalized build #1: https://percy.io/org/project/123
|
|
74
109
|
```
|
|
75
110
|
|
|
76
|
-
### `#
|
|
111
|
+
### `#idle()`
|
|
77
112
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
113
|
+
This method will resolve shortly after pending snapshots and uploads have completed and no more have
|
|
114
|
+
started. Queued tasks are not considered pending unless they are actively running, so deferred
|
|
115
|
+
uploads will not be awaited on with this method.
|
|
81
116
|
|
|
82
117
|
``` js
|
|
83
|
-
|
|
84
|
-
percy.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
execute: async (page) => {}, // additional snapshot page function
|
|
92
|
-
...options // ...additional snapshot options
|
|
93
|
-
}],
|
|
94
|
-
...options // ...other snapshot options
|
|
95
|
-
})
|
|
118
|
+
percy.snapshot(...);
|
|
119
|
+
percy.snapshot(...);
|
|
120
|
+
percy.snapshot(...);
|
|
121
|
+
|
|
122
|
+
await percy.idle()
|
|
123
|
+
// [percy] Snapshot taken: ...
|
|
124
|
+
// [percy] Snapshot taken: ...
|
|
125
|
+
// [percy] Snapshot taken: ...
|
|
96
126
|
```
|
|
97
127
|
|
|
98
|
-
### `#
|
|
128
|
+
### `#snapshot(options)`
|
|
99
129
|
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
Performs asset discovery for a snapshot and queues uploading the snapshot to the associated Percy
|
|
131
|
+
build. When an existing DOM snapshot is provided, it is served as the root resource during asset
|
|
132
|
+
discovery. When no existing DOM snapshot is provided, a new one will be captured using any provided
|
|
133
|
+
snapshot capture options.
|
|
102
134
|
|
|
103
135
|
``` js
|
|
104
|
-
await
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
// snapshots can be handled concurrently, no need to await
|
|
137
|
+
percy.snapshot({
|
|
138
|
+
name: 'Snapshot 1',
|
|
139
|
+
url: 'http://localhost:3000',
|
|
140
|
+
domSnapshot: domSnapshot,
|
|
141
|
+
clientInfo: 'my-sdk',
|
|
142
|
+
environmentInfo: 'my-lib'
|
|
143
|
+
...snapshotOptions
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
percy.snapshot({
|
|
147
|
+
name: 'Snapshot 2',
|
|
148
|
+
url: 'http://localhost:3000/',
|
|
149
|
+
...snapshotOptions,
|
|
150
|
+
|
|
151
|
+
// without a domSnapshot, capture options will be used to take one
|
|
152
|
+
waitForTimeout: 1000,
|
|
153
|
+
waitForSelector: '.done-loading',
|
|
154
|
+
execute: async () => {},
|
|
155
|
+
additionalSnapshots: [{
|
|
156
|
+
name: 'Snapshot 2.1',
|
|
157
|
+
execute: () => {},
|
|
158
|
+
...snapshotOptions
|
|
159
|
+
}]
|
|
160
|
+
})
|
|
110
161
|
```
|
|
111
162
|
|
|
163
|
+
#### Options
|
|
164
|
+
|
|
165
|
+
- `url` — Snapshot URL (**required**)
|
|
166
|
+
- `name` — Snapshot name
|
|
167
|
+
- `domSnapshot` — Snapshot DOM string
|
|
168
|
+
- `clientInfo` — Additional client info
|
|
169
|
+
- `environmentInfo` — Additional environment info
|
|
170
|
+
- `discovery` - Limited snapshot specific discovery options
|
|
171
|
+
- `allowedHostnames`, `requestHeaders`, `authorization`, `disableCache`, `userAgent`
|
|
172
|
+
|
|
173
|
+
**Capture options** can only be provided when `domSnapshot` is missing.
|
|
174
|
+
|
|
175
|
+
- `waitForTimeout` — Milliseconds to wait before taking a snapshot
|
|
176
|
+
- `waitForSelector` — CSS selector to wait for before taking a snapshot
|
|
177
|
+
- `execute` — Function or function body to execute within the page before taking a snapshot
|
|
178
|
+
- `additionalSnapshots` — Array of additional sequential snapshots to take of the page
|
|
179
|
+
- `name` — Snapshot name (**required** if no `prefix` or `suffix`)
|
|
180
|
+
- `prefix` — Snapshot name prefix (**required** if no `name` or `suffix`)
|
|
181
|
+
- `suffix` — Snapshot name suffix (**required** if no `name` or `prefix`)
|
|
182
|
+
- `waitForTimeout`, `waitForSelector`, `execute` — See above
|
|
183
|
+
|
|
184
|
+
Common snapshot options are also accepted and will override instance snapshot options. [See instance
|
|
185
|
+
options](#options) for common snapshot and discovery options.
|
|
186
|
+
|
|
112
187
|
## Advanced
|
|
113
188
|
|
|
114
|
-
###
|
|
189
|
+
### Download discovery browser on install
|
|
190
|
+
|
|
191
|
+
By default, the browser is only downloaded when asset discovery is started for the first time. This
|
|
192
|
+
is because many features of the CLI do not require a browser at all, and automatically downloading a
|
|
193
|
+
browser creates a much heavier footprint than needed for those features. However, if your CI caches
|
|
194
|
+
dependencies after the install step, the browser will not be cached and will be downloaded every
|
|
195
|
+
time Percy runs without it.
|
|
196
|
+
|
|
197
|
+
If the environment variable `PERCY_POSTINSTALL_BROWSER` is present and truthy, then the browser will
|
|
198
|
+
be downloaded after the package is installed to allow it to be cached. You can also require
|
|
199
|
+
`@percy/core/post-install` within another node module to trigger the browser download manually.
|
|
200
|
+
|
|
201
|
+
### Skipping discovery browser download
|
|
115
202
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
203
|
+
If your CI comes with a Chromium binary pre-installed and you wish to skip Percy's own browser
|
|
204
|
+
installation, you can set the respective `discovery.launchOptions.executable` config option. When
|
|
205
|
+
the executable at the provided path exists, the default download will be skipped and the provided
|
|
206
|
+
binary will be used instead. This option can also be set using the `PERCY_BROWSER_EXECUTABLE`
|
|
207
|
+
environment variable.
|
|
119
208
|
|
|
209
|
+
> **Warning!** Percy is only tested against the browser it downloads automatically. When providing a
|
|
210
|
+
> custom browser executable, you may experience unexpected issues.
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.default = exports.Browser = void 0;
|
|
7
|
+
|
|
8
|
+
var _os = _interopRequireDefault(require("os"));
|
|
9
|
+
|
|
10
|
+
var _path = _interopRequireDefault(require("path"));
|
|
11
|
+
|
|
12
|
+
var _fs = require("fs");
|
|
13
|
+
|
|
14
|
+
var _crossSpawn = _interopRequireDefault(require("cross-spawn"));
|
|
15
|
+
|
|
16
|
+
var _events = _interopRequireDefault(require("events"));
|
|
17
|
+
|
|
18
|
+
var _ws = _interopRequireDefault(require("ws"));
|
|
19
|
+
|
|
20
|
+
var _rimraf = _interopRequireDefault(require("rimraf"));
|
|
21
|
+
|
|
22
|
+
var _logger = _interopRequireDefault(require("@percy/logger"));
|
|
23
|
+
|
|
24
|
+
var _install = _interopRequireDefault(require("./install"));
|
|
25
|
+
|
|
26
|
+
var _session = _interopRequireDefault(require("./session"));
|
|
27
|
+
|
|
28
|
+
var _page = _interopRequireDefault(require("./page"));
|
|
29
|
+
|
|
30
|
+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
31
|
+
|
|
32
|
+
function _classPrivateFieldInitSpec(obj, privateMap, value) { _checkPrivateRedeclaration(obj, privateMap); privateMap.set(obj, value); }
|
|
33
|
+
|
|
34
|
+
function _checkPrivateRedeclaration(obj, privateCollection) { if (privateCollection.has(obj)) { throw new TypeError("Cannot initialize the same private elements twice on an object"); } }
|
|
35
|
+
|
|
36
|
+
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
|
|
37
|
+
|
|
38
|
+
function _classPrivateFieldSet(receiver, privateMap, value) { var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); _classApplyDescriptorSet(receiver, descriptor, value); return value; }
|
|
39
|
+
|
|
40
|
+
function _classApplyDescriptorSet(receiver, descriptor, value) { if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } }
|
|
41
|
+
|
|
42
|
+
function _classPrivateFieldGet(receiver, privateMap) { var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "get"); return _classApplyDescriptorGet(receiver, descriptor); }
|
|
43
|
+
|
|
44
|
+
function _classExtractFieldDescriptor(receiver, privateMap, action) { if (!privateMap.has(receiver)) { throw new TypeError("attempted to " + action + " private field on non-instance"); } return privateMap.get(receiver); }
|
|
45
|
+
|
|
46
|
+
function _classApplyDescriptorGet(receiver, descriptor) { if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
|
|
47
|
+
|
|
48
|
+
var _callbacks = /*#__PURE__*/new WeakMap();
|
|
49
|
+
|
|
50
|
+
var _lastid = /*#__PURE__*/new WeakMap();
|
|
51
|
+
|
|
52
|
+
class Browser extends _events.default {
|
|
53
|
+
constructor({
|
|
54
|
+
executable = process.env.PERCY_BROWSER_EXECUTABLE,
|
|
55
|
+
headless = true,
|
|
56
|
+
cookies = [],
|
|
57
|
+
args = [],
|
|
58
|
+
timeout
|
|
59
|
+
}) {
|
|
60
|
+
super();
|
|
61
|
+
|
|
62
|
+
_defineProperty(this, "log", (0, _logger.default)('core:browser'));
|
|
63
|
+
|
|
64
|
+
_defineProperty(this, "sessions", new Map());
|
|
65
|
+
|
|
66
|
+
_defineProperty(this, "readyState", null);
|
|
67
|
+
|
|
68
|
+
_defineProperty(this, "closed", false);
|
|
69
|
+
|
|
70
|
+
_classPrivateFieldInitSpec(this, _callbacks, {
|
|
71
|
+
writable: true,
|
|
72
|
+
value: new Map()
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
_classPrivateFieldInitSpec(this, _lastid, {
|
|
76
|
+
writable: true,
|
|
77
|
+
value: 0
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
_defineProperty(this, "args", [// disable the translate popup
|
|
81
|
+
'--disable-features=Translate', // disable several subsystems which run network requests in the background
|
|
82
|
+
'--disable-background-networking', // disable task throttling of timer tasks from background pages
|
|
83
|
+
'--disable-background-timer-throttling', // disable backgrounding renderer processes
|
|
84
|
+
'--disable-renderer-backgrounding', // disable backgrounding renderers for occluded windows (reduce nondeterminism)
|
|
85
|
+
'--disable-backgrounding-occluded-windows', // disable crash reporting
|
|
86
|
+
'--disable-breakpad', // disable client side phishing detection
|
|
87
|
+
'--disable-client-side-phishing-detection', // disable default component extensions with background pages for performance
|
|
88
|
+
'--disable-component-extensions-with-background-pages', // disable installation of default apps on first run
|
|
89
|
+
'--disable-default-apps', // work-around for environments where a small /dev/shm partition causes crashes
|
|
90
|
+
'--disable-dev-shm-usage', // disable extensions
|
|
91
|
+
'--disable-extensions', // disable hang monitor dialogs in renderer processes
|
|
92
|
+
'--disable-hang-monitor', // disable inter-process communication flooding protection for javascript
|
|
93
|
+
'--disable-ipc-flooding-protection', // disable web notifications and the push API
|
|
94
|
+
'--disable-notifications', // disable the prompt when a POST request causes page navigation
|
|
95
|
+
'--disable-prompt-on-repost', // disable syncing browser data with google accounts
|
|
96
|
+
'--disable-sync', // disable site-isolation to make network requests easier to intercept
|
|
97
|
+
'--disable-site-isolation-trials', // disable the first run tasks, whether or not it's actually the first run
|
|
98
|
+
'--no-first-run', // disable the sandbox for all process types that are normally sandboxed
|
|
99
|
+
'--no-sandbox', // enable indication that browser is controlled by automation
|
|
100
|
+
'--enable-automation', // specify a consistent encryption backend across platforms
|
|
101
|
+
'--password-store=basic', // use a mock keychain on Mac to prevent blocking permissions dialogs
|
|
102
|
+
'--use-mock-keychain', // enable remote debugging on the first available port
|
|
103
|
+
'--remote-debugging-port=0']);
|
|
104
|
+
|
|
105
|
+
this.launchTimeout = timeout;
|
|
106
|
+
this.executable = executable;
|
|
107
|
+
this.headless = headless;
|
|
108
|
+
/* istanbul ignore next: only false for debugging */
|
|
109
|
+
|
|
110
|
+
if (this.headless) this.args.push('--headless', '--hide-scrollbars', '--mute-audio');
|
|
111
|
+
|
|
112
|
+
for (let a of args) if (!this.args.includes(a)) this.args.push(a); // transform cookies object to an array of cookie params
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
this.cookies = Array.isArray(cookies) ? cookies : Object.entries(cookies).map(([name, value]) => ({
|
|
116
|
+
name,
|
|
117
|
+
value
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async launch() {
|
|
122
|
+
// already launching or launched
|
|
123
|
+
if (this.readyState != null) return;
|
|
124
|
+
this.readyState = 0; // check if any provided executable exists
|
|
125
|
+
|
|
126
|
+
if (this.executable && !(0, _fs.existsSync)(this.executable)) {
|
|
127
|
+
this.log.error(`Browser executable not found: ${this.executable}`);
|
|
128
|
+
this.executable = null;
|
|
129
|
+
} // download and install the browser if not already present
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
this.executable || (this.executable = await _install.default.chromium()); // create a temporary profile directory
|
|
133
|
+
|
|
134
|
+
this.profile = await _fs.promises.mkdtemp(_path.default.join(_os.default.tmpdir(), 'percy-browser-')); // spawn the browser process detached in its own group and session
|
|
135
|
+
|
|
136
|
+
let args = this.args.concat(`--user-data-dir=${this.profile}`);
|
|
137
|
+
this.log.debug('Launching browser');
|
|
138
|
+
this.process = (0, _crossSpawn.default)(this.executable, args, {
|
|
139
|
+
detached: process.platform !== 'win32'
|
|
140
|
+
}); // connect a websocket to the devtools address
|
|
141
|
+
|
|
142
|
+
let addr = await this.address(this.launchTimeout);
|
|
143
|
+
this.ws = new _ws.default(addr, {
|
|
144
|
+
perMessageDeflate: false
|
|
145
|
+
}); // wait until the websocket has connected
|
|
146
|
+
|
|
147
|
+
await new Promise(resolve => this.ws.once('open', resolve));
|
|
148
|
+
this.ws.on('message', data => this._handleMessage(data)); // get version information
|
|
149
|
+
|
|
150
|
+
this.version = await this.send('Browser.getVersion');
|
|
151
|
+
this.log.debug(`Browser connected [${this.process.pid}]: ${this.version.product}`);
|
|
152
|
+
this.readyState = 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
isConnected() {
|
|
156
|
+
var _this$ws;
|
|
157
|
+
|
|
158
|
+
return ((_this$ws = this.ws) === null || _this$ws === void 0 ? void 0 : _this$ws.readyState) === _ws.default.OPEN;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async close() {
|
|
162
|
+
var _this$process4, _this$ws2;
|
|
163
|
+
|
|
164
|
+
// not running, already closed, or closing
|
|
165
|
+
if (this._closed) return this._closed;
|
|
166
|
+
this.readyState = 2;
|
|
167
|
+
this.log.debug('Closing browser'); // resolves when the browser has closed
|
|
168
|
+
|
|
169
|
+
this._closed = Promise.all([new Promise(resolve => {
|
|
170
|
+
/* istanbul ignore next: race condition paranoia */
|
|
171
|
+
if (!this.process || this.process.exitCode) resolve();else this.process.on('exit', resolve);
|
|
172
|
+
}), new Promise(resolve => {
|
|
173
|
+
/* istanbul ignore next: race condition paranoia */
|
|
174
|
+
if (!this.isConnected()) resolve();else this.ws.on('close', resolve);
|
|
175
|
+
})]).then(() => {
|
|
176
|
+
var _this$process, _this$process2, _this$process3;
|
|
177
|
+
|
|
178
|
+
// needed due to a bug in Node 12 - https://github.com/nodejs/node/issues/27097
|
|
179
|
+
(_this$process = this.process) === null || _this$process === void 0 ? void 0 : _this$process.stdin.end();
|
|
180
|
+
(_this$process2 = this.process) === null || _this$process2 === void 0 ? void 0 : _this$process2.stdout.end();
|
|
181
|
+
(_this$process3 = this.process) === null || _this$process3 === void 0 ? void 0 : _this$process3.stderr.end();
|
|
182
|
+
/* istanbul ignore next:
|
|
183
|
+
* this might fail on some systems but ultimately it is just a temp file */
|
|
184
|
+
|
|
185
|
+
if (this.profile) {
|
|
186
|
+
// attempt to clean up the profile directory
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
(0, _rimraf.default)(this.profile, e => e ? reject(e) : resolve());
|
|
189
|
+
}).catch(error => {
|
|
190
|
+
this.log.debug('Could not clean up temporary browser profile directory.');
|
|
191
|
+
this.log.debug(error);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}).then(() => {
|
|
195
|
+
this.log.debug('Browser closed');
|
|
196
|
+
this.readyState = 3;
|
|
197
|
+
}); // reject any pending callbacks
|
|
198
|
+
|
|
199
|
+
for (let callback of _classPrivateFieldGet(this, _callbacks).values()) {
|
|
200
|
+
callback.reject(Object.assign(callback.error, {
|
|
201
|
+
message: `Protocol error (${callback.method}): Browser closed.`
|
|
202
|
+
}));
|
|
203
|
+
} // trigger rejecting pending session callbacks
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
for (let session of this.sessions.values()) {
|
|
207
|
+
session._handleClose();
|
|
208
|
+
} // clear own callbacks and sessions
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_classPrivateFieldGet(this, _callbacks).clear();
|
|
212
|
+
|
|
213
|
+
this.sessions.clear();
|
|
214
|
+
/* istanbul ignore next:
|
|
215
|
+
* difficult to test failure here without mocking private properties */
|
|
216
|
+
|
|
217
|
+
if ((_this$process4 = this.process) !== null && _this$process4 !== void 0 && _this$process4.pid && !this.process.killed) {
|
|
218
|
+
// always force close the browser process
|
|
219
|
+
try {
|
|
220
|
+
this.process.kill('SIGKILL');
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new Error(`Unable to close the browser: ${error.stack}`);
|
|
223
|
+
}
|
|
224
|
+
} // close the socket connection
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
(_this$ws2 = this.ws) === null || _this$ws2 === void 0 ? void 0 : _this$ws2.close(); // wait for the browser to close
|
|
228
|
+
|
|
229
|
+
return this._closed;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async page(options = {}) {
|
|
233
|
+
let {
|
|
234
|
+
targetId
|
|
235
|
+
} = await this.send('Target.createTarget', {
|
|
236
|
+
url: ''
|
|
237
|
+
});
|
|
238
|
+
let {
|
|
239
|
+
sessionId
|
|
240
|
+
} = await this.send('Target.attachToTarget', {
|
|
241
|
+
targetId,
|
|
242
|
+
flatten: true
|
|
243
|
+
});
|
|
244
|
+
let page = new _page.default(this.sessions.get(sessionId), options);
|
|
245
|
+
await page._handleAttachedToTarget();
|
|
246
|
+
return page;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async send(method, params) {
|
|
250
|
+
/* istanbul ignore next:
|
|
251
|
+
* difficult to test failure here without mocking private properties */
|
|
252
|
+
if (!this.isConnected()) throw new Error('Browser not connected'); // every command needs a unique id
|
|
253
|
+
|
|
254
|
+
let id = _classPrivateFieldSet(this, _lastid, +_classPrivateFieldGet(this, _lastid) + 1);
|
|
255
|
+
|
|
256
|
+
if (!params && typeof method === 'object') {
|
|
257
|
+
// allow providing a raw message as the only argument and return the id
|
|
258
|
+
this.ws.send(JSON.stringify({ ...method,
|
|
259
|
+
id
|
|
260
|
+
}));
|
|
261
|
+
return id;
|
|
262
|
+
} else {
|
|
263
|
+
// send the message payload
|
|
264
|
+
this.ws.send(JSON.stringify({
|
|
265
|
+
id,
|
|
266
|
+
method,
|
|
267
|
+
params
|
|
268
|
+
})); // will resolve or reject when a matching response is received
|
|
269
|
+
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
_classPrivateFieldGet(this, _callbacks).set(id, {
|
|
272
|
+
error: new Error(),
|
|
273
|
+
resolve,
|
|
274
|
+
reject,
|
|
275
|
+
method
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
} // Returns the devtools websocket address. If not already known, will watch the browser's
|
|
280
|
+
// stderr and resolves when it emits the devtools protocol address or rejects if the process
|
|
281
|
+
// exits for any reason or if the address does not appear after the timeout.
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async address(timeout = 30000) {
|
|
285
|
+
this._address || (this._address = await new Promise((resolve, reject) => {
|
|
286
|
+
let stderr = '';
|
|
287
|
+
|
|
288
|
+
let handleData = chunk => {
|
|
289
|
+
stderr += chunk = chunk.toString();
|
|
290
|
+
let match = chunk.match(/^DevTools listening on (ws:\/\/.*)$/m);
|
|
291
|
+
if (match) cleanup(() => resolve(match[1]));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
let handleExitClose = () => handleError();
|
|
295
|
+
|
|
296
|
+
let handleError = error => cleanup(() => {
|
|
297
|
+
var _error$message;
|
|
298
|
+
|
|
299
|
+
return reject(new Error(`Failed to launch browser. ${(_error$message = error === null || error === void 0 ? void 0 : error.message) !== null && _error$message !== void 0 ? _error$message : ''}\n${stderr}'\n\n`));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
let cleanup = callback => {
|
|
303
|
+
clearTimeout(timeoutId);
|
|
304
|
+
this.process.stderr.off('data', handleData);
|
|
305
|
+
this.process.stderr.off('close', handleExitClose);
|
|
306
|
+
this.process.off('exit', handleExitClose);
|
|
307
|
+
this.process.off('error', handleError);
|
|
308
|
+
callback();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
let timeoutId = setTimeout(() => handleError(new Error(`Timed out after ${timeout}ms`)), timeout);
|
|
312
|
+
this.process.stderr.on('data', handleData);
|
|
313
|
+
this.process.stderr.on('close', handleExitClose);
|
|
314
|
+
this.process.on('exit', handleExitClose);
|
|
315
|
+
this.process.on('error', handleError);
|
|
316
|
+
}));
|
|
317
|
+
return this._address;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_handleMessage(data) {
|
|
321
|
+
data = JSON.parse(data);
|
|
322
|
+
|
|
323
|
+
if (data.method === 'Target.attachedToTarget') {
|
|
324
|
+
// create a new session reference when attached to a target
|
|
325
|
+
let session = new _session.default(this, data);
|
|
326
|
+
this.sessions.set(session.sessionId, session);
|
|
327
|
+
} else if (data.method === 'Target.detachedFromTarget') {
|
|
328
|
+
// remove the old session reference when detached from a target
|
|
329
|
+
let session = this.sessions.get(data.params.sessionId);
|
|
330
|
+
this.sessions.delete(data.params.sessionId);
|
|
331
|
+
session === null || session === void 0 ? void 0 : session._handleClose();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (data.sessionId) {
|
|
335
|
+
// message was for a specific session that sent it
|
|
336
|
+
let session = this.sessions.get(data.sessionId);
|
|
337
|
+
session === null || session === void 0 ? void 0 : session._handleMessage(data);
|
|
338
|
+
} else if (data.id && _classPrivateFieldGet(this, _callbacks).has(data.id)) {
|
|
339
|
+
// resolve or reject a pending promise created with #send()
|
|
340
|
+
let callback = _classPrivateFieldGet(this, _callbacks).get(data.id);
|
|
341
|
+
|
|
342
|
+
_classPrivateFieldGet(this, _callbacks).delete(data.id);
|
|
343
|
+
/* istanbul ignore next: races with page._handleMessage() */
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if (data.error) {
|
|
347
|
+
callback.reject(Object.assign(callback.error, {
|
|
348
|
+
message: `Protocol error (${callback.method}): ${data.error.message}` + ('data' in data.error ? `: ${data.error.data}` : '')
|
|
349
|
+
}));
|
|
350
|
+
} else {
|
|
351
|
+
callback.resolve(data.result);
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
// emit the message as an event
|
|
355
|
+
this.emit(data.method, data.params);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
exports.Browser = Browser;
|
|
362
|
+
var _default = Browser;
|
|
363
|
+
exports.default = _default;
|