@percy/core 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.
@@ -0,0 +1,292 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import spawn from 'cross-spawn';
5
+ import EventEmitter from 'events';
6
+ import WebSocket from 'ws';
7
+ import rimraf from 'rimraf';
8
+ import logger from '@percy/logger';
9
+ import install from './install.js';
10
+ import Session from './session.js';
11
+ import Page from './page.js';
12
+ export class Browser extends EventEmitter {
13
+ log = logger('core:browser');
14
+ sessions = new Map();
15
+ readyState = null;
16
+ closed = false;
17
+ #callbacks = new Map();
18
+ #lastid = 0;
19
+ args = [// disable the translate popup
20
+ '--disable-features=Translate', // disable several subsystems which run network requests in the background
21
+ '--disable-background-networking', // disable task throttling of timer tasks from background pages
22
+ '--disable-background-timer-throttling', // disable backgrounding renderer processes
23
+ '--disable-renderer-backgrounding', // disable backgrounding renderers for occluded windows (reduce nondeterminism)
24
+ '--disable-backgrounding-occluded-windows', // disable crash reporting
25
+ '--disable-breakpad', // disable client side phishing detection
26
+ '--disable-client-side-phishing-detection', // disable default component extensions with background pages for performance
27
+ '--disable-component-extensions-with-background-pages', // disable installation of default apps on first run
28
+ '--disable-default-apps', // work-around for environments where a small /dev/shm partition causes crashes
29
+ '--disable-dev-shm-usage', // disable extensions
30
+ '--disable-extensions', // disable hang monitor dialogs in renderer processes
31
+ '--disable-hang-monitor', // disable inter-process communication flooding protection for javascript
32
+ '--disable-ipc-flooding-protection', // disable web notifications and the push API
33
+ '--disable-notifications', // disable the prompt when a POST request causes page navigation
34
+ '--disable-prompt-on-repost', // disable syncing browser data with google accounts
35
+ '--disable-sync', // disable site-isolation to make network requests easier to intercept
36
+ '--disable-site-isolation-trials', // disable the first run tasks, whether or not it's actually the first run
37
+ '--no-first-run', // disable the sandbox for all process types that are normally sandboxed
38
+ '--no-sandbox', // enable indication that browser is controlled by automation
39
+ '--enable-automation', // specify a consistent encryption backend across platforms
40
+ '--password-store=basic', // use a mock keychain on Mac to prevent blocking permissions dialogs
41
+ '--use-mock-keychain', // enable remote debugging on the first available port
42
+ '--remote-debugging-port=0'];
43
+
44
+ constructor({
45
+ executable = process.env.PERCY_BROWSER_EXECUTABLE,
46
+ timeout = 30000,
47
+ headless = true,
48
+ cookies = [],
49
+ args = []
50
+ }) {
51
+ super();
52
+ this.launchTimeout = timeout;
53
+ this.executable = executable;
54
+ this.headless = headless;
55
+ /* istanbul ignore next: only false for debugging */
56
+
57
+ if (this.headless) this.args.push('--headless', '--hide-scrollbars', '--mute-audio');
58
+
59
+ for (let a of args) if (!this.args.includes(a)) this.args.push(a); // transform cookies object to an array of cookie params
60
+
61
+
62
+ this.cookies = Array.isArray(cookies) ? cookies : Object.entries(cookies).map(([name, value]) => ({
63
+ name,
64
+ value
65
+ }));
66
+ }
67
+
68
+ async launch() {
69
+ // already launching or launched
70
+ if (this.readyState != null) return;
71
+ this.readyState = 0; // check if any provided executable exists
72
+
73
+ if (this.executable && !fs.existsSync(this.executable)) {
74
+ this.log.error(`Browser executable not found: ${this.executable}`);
75
+ this.executable = null;
76
+ } // download and install the browser if not already present
77
+
78
+
79
+ this.executable || (this.executable = await install.chromium()); // create a temporary profile directory
80
+
81
+ this.profile = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'percy-browser-')); // spawn the browser process detached in its own group and session
82
+
83
+ let args = this.args.concat(`--user-data-dir=${this.profile}`);
84
+ this.log.debug('Launching browser');
85
+ this.process = spawn(this.executable, args, {
86
+ detached: process.platform !== 'win32'
87
+ }); // connect a websocket to the devtools address
88
+
89
+ this.ws = new WebSocket(await this.address(), {
90
+ perMessageDeflate: false
91
+ }); // wait until the websocket has connected
92
+
93
+ await new Promise(resolve => this.ws.once('open', resolve));
94
+ this.ws.on('message', data => this._handleMessage(data)); // get version information
95
+
96
+ this.version = await this.send('Browser.getVersion');
97
+ this.log.debug(`Browser connected [${this.process.pid}]: ${this.version.product}`);
98
+ this.readyState = 1;
99
+ }
100
+
101
+ isConnected() {
102
+ var _this$ws;
103
+
104
+ return ((_this$ws = this.ws) === null || _this$ws === void 0 ? void 0 : _this$ws.readyState) === WebSocket.OPEN;
105
+ }
106
+
107
+ async close() {
108
+ var _this$process, _this$ws2;
109
+
110
+ // not running, already closed, or closing
111
+ if (this._closed) return this._closed;
112
+ this.readyState = 2;
113
+ this.log.debug('Closing browser'); // resolves when the browser has closed
114
+
115
+ this._closed = Promise.all([new Promise(resolve => {
116
+ /* istanbul ignore next: race condition paranoia */
117
+ if (!this.process || this.process.exitCode) resolve();else this.process.on('exit', resolve);
118
+ }), new Promise(resolve => {
119
+ /* istanbul ignore next: race condition paranoia */
120
+ if (!this.isConnected()) resolve();else this.ws.on('close', resolve);
121
+ })]).then(() => {
122
+ /* istanbul ignore next:
123
+ * this might fail on some systems but ultimately it is just a temp file */
124
+ if (this.profile) {
125
+ // attempt to clean up the profile directory
126
+ return new Promise((resolve, reject) => {
127
+ rimraf(this.profile, e => e ? reject(e) : resolve());
128
+ }).catch(error => {
129
+ this.log.debug('Could not clean up temporary browser profile directory.');
130
+ this.log.debug(error);
131
+ });
132
+ }
133
+ }).then(() => {
134
+ this.log.debug('Browser closed');
135
+ this.readyState = 3;
136
+ }); // reject any pending callbacks
137
+
138
+ for (let callback of this.#callbacks.values()) {
139
+ callback.reject(Object.assign(callback.error, {
140
+ message: `Protocol error (${callback.method}): Browser closed.`
141
+ }));
142
+ } // trigger rejecting pending session callbacks
143
+
144
+
145
+ for (let session of this.sessions.values()) {
146
+ session._handleClose();
147
+ } // clear own callbacks and sessions
148
+
149
+
150
+ this.#callbacks.clear();
151
+ this.sessions.clear();
152
+ /* istanbul ignore next:
153
+ * difficult to test failure here without mocking private properties */
154
+
155
+ if ((_this$process = this.process) !== null && _this$process !== void 0 && _this$process.pid && !this.process.killed) {
156
+ // always force close the browser process
157
+ try {
158
+ this.process.kill('SIGKILL');
159
+ } catch (error) {
160
+ throw new Error(`Unable to close the browser: ${error.stack}`);
161
+ }
162
+ } // close the socket connection
163
+
164
+
165
+ (_this$ws2 = this.ws) === null || _this$ws2 === void 0 ? void 0 : _this$ws2.close(); // wait for the browser to close
166
+
167
+ return this._closed;
168
+ }
169
+
170
+ async page(options = {}) {
171
+ let {
172
+ targetId
173
+ } = await this.send('Target.createTarget', {
174
+ url: ''
175
+ });
176
+ let {
177
+ sessionId
178
+ } = await this.send('Target.attachToTarget', {
179
+ targetId,
180
+ flatten: true
181
+ });
182
+ let page = new Page(this.sessions.get(sessionId), options);
183
+ await page._handleAttachedToTarget();
184
+ return page;
185
+ }
186
+
187
+ async send(method, params) {
188
+ /* istanbul ignore next:
189
+ * difficult to test failure here without mocking private properties */
190
+ if (!this.isConnected()) throw new Error('Browser not connected'); // every command needs a unique id
191
+
192
+ let id = ++this.#lastid;
193
+
194
+ if (!params && typeof method === 'object') {
195
+ // allow providing a raw message as the only argument and return the id
196
+ this.ws.send(JSON.stringify({ ...method,
197
+ id
198
+ }));
199
+ return id;
200
+ } else {
201
+ // send the message payload
202
+ this.ws.send(JSON.stringify({
203
+ id,
204
+ method,
205
+ params
206
+ })); // will resolve or reject when a matching response is received
207
+
208
+ return new Promise((resolve, reject) => {
209
+ this.#callbacks.set(id, {
210
+ error: new Error(),
211
+ resolve,
212
+ reject,
213
+ method
214
+ });
215
+ });
216
+ }
217
+ } // Returns the devtools websocket address. If not already known, will watch the browser's
218
+ // stderr and resolves when it emits the devtools protocol address or rejects if the process
219
+ // exits for any reason or if the address does not appear after the timeout.
220
+
221
+
222
+ async address(timeout = this.launchTimeout) {
223
+ this._address || (this._address = await new Promise((resolve, reject) => {
224
+ let stderr = '';
225
+
226
+ let handleData = chunk => {
227
+ stderr += chunk = chunk.toString();
228
+ let match = chunk.match(/^DevTools listening on (ws:\/\/.*)$/m);
229
+ if (match) cleanup(() => resolve(match[1]));
230
+ };
231
+
232
+ let handleExitClose = () => handleError();
233
+
234
+ let handleError = error => cleanup(() => reject(new Error(`Failed to launch browser. ${(error === null || error === void 0 ? void 0 : error.message) ?? ''}\n${stderr}'\n\n`)));
235
+
236
+ let cleanup = callback => {
237
+ clearTimeout(timeoutId);
238
+ this.process.stderr.off('data', handleData);
239
+ this.process.stderr.off('close', handleExitClose);
240
+ this.process.off('exit', handleExitClose);
241
+ this.process.off('error', handleError);
242
+ callback();
243
+ };
244
+
245
+ let timeoutId = setTimeout(() => handleError(new Error(`Timed out after ${timeout}ms`)), timeout);
246
+ this.process.stderr.on('data', handleData);
247
+ this.process.stderr.on('close', handleExitClose);
248
+ this.process.on('exit', handleExitClose);
249
+ this.process.on('error', handleError);
250
+ }));
251
+ return this._address;
252
+ }
253
+
254
+ _handleMessage(data) {
255
+ data = JSON.parse(data);
256
+
257
+ if (data.method === 'Target.attachedToTarget') {
258
+ // create a new session reference when attached to a target
259
+ let session = new Session(this, data);
260
+ this.sessions.set(session.sessionId, session);
261
+ } else if (data.method === 'Target.detachedFromTarget') {
262
+ // remove the old session reference when detached from a target
263
+ let session = this.sessions.get(data.params.sessionId);
264
+ this.sessions.delete(data.params.sessionId);
265
+ session === null || session === void 0 ? void 0 : session._handleClose();
266
+ }
267
+
268
+ if (data.sessionId) {
269
+ // message was for a specific session that sent it
270
+ let session = this.sessions.get(data.sessionId);
271
+ session === null || session === void 0 ? void 0 : session._handleMessage(data);
272
+ } else if (data.id && this.#callbacks.has(data.id)) {
273
+ // resolve or reject a pending promise created with #send()
274
+ let callback = this.#callbacks.get(data.id);
275
+ this.#callbacks.delete(data.id);
276
+ /* istanbul ignore next: races with page._handleMessage() */
277
+
278
+ if (data.error) {
279
+ callback.reject(Object.assign(callback.error, {
280
+ message: `Protocol error (${callback.method}): ${data.error.message}` + ('data' in data.error ? `: ${data.error.data}` : '')
281
+ }));
282
+ } else {
283
+ callback.resolve(data.result);
284
+ }
285
+ } else {
286
+ // emit the message as an event
287
+ this.emit(data.method, data.params);
288
+ }
289
+ }
290
+
291
+ }
292
+ export default Browser;