@percy/core 1.0.0 → 1.0.3
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/dist/api.js +94 -0
- package/dist/browser.js +292 -0
- package/dist/config.js +551 -0
- package/dist/discovery.js +118 -0
- package/dist/index.js +5 -0
- package/dist/install.js +156 -0
- package/dist/network.js +298 -0
- package/dist/page.js +264 -0
- package/dist/percy.js +484 -0
- package/dist/queue.js +152 -0
- package/dist/server.js +430 -0
- package/dist/session.js +103 -0
- package/dist/snapshot.js +433 -0
- package/dist/utils.js +127 -0
- package/package.json +11 -11
- package/post-install.js +20 -0
- package/test/helpers/dedent.js +27 -0
- package/test/helpers/index.js +34 -0
- package/test/helpers/request.js +15 -0
- package/test/helpers/server.js +33 -0
- package/types/index.d.ts +101 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import logger from '@percy/logger';
|
|
5
|
+
import { getPackageJSON } from './utils.js';
|
|
6
|
+
import Server from './server.js'; // need require.resolve until import.meta.resolve can be transpiled
|
|
7
|
+
|
|
8
|
+
export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom'); // Create a Percy CLI API server instance
|
|
9
|
+
|
|
10
|
+
export function createPercyServer(percy, port) {
|
|
11
|
+
let pkg = getPackageJSON(import.meta.url);
|
|
12
|
+
return new Server({
|
|
13
|
+
port
|
|
14
|
+
}) // facilitate logger websocket connections
|
|
15
|
+
.websocket(ws => logger.connect(ws)) // general middleware
|
|
16
|
+
.route((req, res, next) => {
|
|
17
|
+
// treat all request bodies as json
|
|
18
|
+
if (req.body) try {
|
|
19
|
+
req.body = JSON.parse(req.body);
|
|
20
|
+
} catch {} // add version header
|
|
21
|
+
|
|
22
|
+
res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version');
|
|
23
|
+
res.setHeader('X-Percy-Core-Version', pkg.version); // return json errors
|
|
24
|
+
|
|
25
|
+
return next().catch(e => res.json(e.status ?? 500, {
|
|
26
|
+
build: percy.build,
|
|
27
|
+
error: e.message,
|
|
28
|
+
success: false
|
|
29
|
+
}));
|
|
30
|
+
}) // healthcheck returns basic information
|
|
31
|
+
.route('get', '/percy/healthcheck', (req, res) => res.json(200, {
|
|
32
|
+
loglevel: percy.loglevel(),
|
|
33
|
+
config: percy.config,
|
|
34
|
+
build: percy.build,
|
|
35
|
+
success: true
|
|
36
|
+
})) // get or set config options
|
|
37
|
+
.route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, {
|
|
38
|
+
config: req.body ? await percy.setConfig(req.body) : percy.config,
|
|
39
|
+
success: true
|
|
40
|
+
})) // responds once idle (may take a long time)
|
|
41
|
+
.route('get', '/percy/idle', async (req, res) => res.json(200, {
|
|
42
|
+
success: await percy.idle().then(() => true)
|
|
43
|
+
})) // convenient @percy/dom bundle
|
|
44
|
+
.route('get', '/percy/dom.js', (req, res) => {
|
|
45
|
+
return res.file(200, PERCY_DOM);
|
|
46
|
+
}) // legacy agent wrapper for @percy/dom
|
|
47
|
+
.route('get', '/percy-agent.js', async (req, res) => {
|
|
48
|
+
logger('core:server').deprecated(['It looks like you’re using @percy/cli with an older SDK.', 'Please upgrade to the latest version to fix this warning.', 'See these docs for more info: https:docs.percy.io/docs/migrating-to-percy-cli'].join(' '));
|
|
49
|
+
let content = await fs.promises.readFile(PERCY_DOM, 'utf-8');
|
|
50
|
+
let wrapper = '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });';
|
|
51
|
+
return res.send(200, 'applicaton/javascript', content.concat(wrapper));
|
|
52
|
+
}) // post one or more snapshots
|
|
53
|
+
.route('post', '/percy/snapshot', async (req, res) => {
|
|
54
|
+
let snapshot = percy.snapshot(req.body);
|
|
55
|
+
if (!req.url.searchParams.has('async')) await snapshot;
|
|
56
|
+
return res.json(200, {
|
|
57
|
+
success: true
|
|
58
|
+
});
|
|
59
|
+
}) // stops percy at the end of the current event loop
|
|
60
|
+
.route('/percy/stop', (req, res) => {
|
|
61
|
+
setImmediate(() => percy.stop());
|
|
62
|
+
return res.json(200, {
|
|
63
|
+
success: true
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
} // Create a static server instance with an automatic sitemap
|
|
67
|
+
|
|
68
|
+
export function createStaticServer(options) {
|
|
69
|
+
let {
|
|
70
|
+
serve,
|
|
71
|
+
port,
|
|
72
|
+
baseUrl = '/',
|
|
73
|
+
...opts
|
|
74
|
+
} = options;
|
|
75
|
+
let server = new Server({
|
|
76
|
+
port
|
|
77
|
+
}).serve(baseUrl, serve, opts); // used when generating an automatic sitemap
|
|
78
|
+
|
|
79
|
+
let toURL = Server.createRewriter( // reverse rewrites' src, dest, & order
|
|
80
|
+
Object.entries((options === null || options === void 0 ? void 0 : options.rewrites) ?? {}).reduce((acc, rw) => [rw.reverse(), ...acc], []), (filename, rewrite) => new URL(path.posix.join(baseUrl, // cleanUrls will trim trailing .html/index.html from paths
|
|
81
|
+
!options.cleanUrls ? rewrite(filename) : rewrite(filename).replace(/(\/index)?\.html$/, '')), server.address())); // include automatic sitemap route
|
|
82
|
+
|
|
83
|
+
server.route('get', '/sitemap.xml', async (req, res) => {
|
|
84
|
+
let {
|
|
85
|
+
default: glob
|
|
86
|
+
} = await import('fast-glob');
|
|
87
|
+
let files = await glob('**/*.html', {
|
|
88
|
+
cwd: serve,
|
|
89
|
+
fs
|
|
90
|
+
});
|
|
91
|
+
return res.send(200, 'application/xml', ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ...files.map(name => ` <url><loc>${toURL(name)}</loc></url>`), '</urlset>'].join('\n'));
|
|
92
|
+
});
|
|
93
|
+
return server;
|
|
94
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -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;
|