@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/page.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import logger from '@percy/logger';
|
|
3
|
+
import Network from './network.js';
|
|
4
|
+
import { hostname, generatePromise, waitFor } from './utils.js';
|
|
5
|
+
import { PERCY_DOM } from './api.js';
|
|
6
|
+
export class Page {
|
|
7
|
+
static TIMEOUT = 30000;
|
|
8
|
+
log = logger('core:page');
|
|
9
|
+
|
|
10
|
+
constructor(session, options) {
|
|
11
|
+
this.session = session;
|
|
12
|
+
this.enableJavaScript = options.enableJavaScript ?? true;
|
|
13
|
+
this.network = new Network(this, options);
|
|
14
|
+
this.meta = options.meta;
|
|
15
|
+
session.on('Runtime.executionContextCreated', this._handleExecutionContextCreated);
|
|
16
|
+
session.on('Runtime.executionContextDestroyed', this._handleExecutionContextDestroyed);
|
|
17
|
+
session.on('Runtime.executionContextsCleared', this._handleExecutionContextsCleared);
|
|
18
|
+
session.send('Runtime.enable').catch(session._handleClosedError);
|
|
19
|
+
this.log.debug('Page created');
|
|
20
|
+
} // Close the page
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async close() {
|
|
24
|
+
await this.session.close();
|
|
25
|
+
this.log.debug('Page closed', this.meta);
|
|
26
|
+
} // Resize the page to the specified width and height
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async resize({
|
|
30
|
+
width,
|
|
31
|
+
height
|
|
32
|
+
}) {
|
|
33
|
+
this.log.debug(`Resize page to ${width}x${height}`);
|
|
34
|
+
await this.session.send('Emulation.setDeviceMetricsOverride', {
|
|
35
|
+
deviceScaleFactor: 1,
|
|
36
|
+
mobile: false,
|
|
37
|
+
height,
|
|
38
|
+
width
|
|
39
|
+
});
|
|
40
|
+
} // Go to a URL and wait for navigation to occur
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async goto(url, {
|
|
44
|
+
waitUntil = 'load'
|
|
45
|
+
} = {}) {
|
|
46
|
+
this.log.debug(`Navigate to: ${url}`, this.meta);
|
|
47
|
+
|
|
48
|
+
let navigate = async () => {
|
|
49
|
+
// set cookies before navigation so we can default the domain to this hostname
|
|
50
|
+
if (this.session.browser.cookies.length) {
|
|
51
|
+
let defaultDomain = hostname(url);
|
|
52
|
+
await this.session.send('Network.setCookies', {
|
|
53
|
+
// spread is used to make a shallow copy of the cookie
|
|
54
|
+
cookies: this.session.browser.cookies.map(({ ...cookie
|
|
55
|
+
}) => {
|
|
56
|
+
if (!cookie.url) cookie.domain || (cookie.domain = defaultDomain);
|
|
57
|
+
return cookie;
|
|
58
|
+
})
|
|
59
|
+
});
|
|
60
|
+
} // handle navigation errors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
let res = await this.session.send('Page.navigate', {
|
|
64
|
+
url
|
|
65
|
+
});
|
|
66
|
+
if (res.errorText) throw new Error(res.errorText);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let handlers = [// wait until navigation and the correct lifecycle
|
|
70
|
+
['Page.frameNavigated', e => this.session.targetId === e.frame.id], ['Page.lifecycleEvent', e => this.session.targetId === e.frameId && e.name === waitUntil]].map(([name, cond]) => {
|
|
71
|
+
let handler = e => cond(e) && (handler.finished = true) && handler.off();
|
|
72
|
+
|
|
73
|
+
handler.off = () => this.session.off(name, handler);
|
|
74
|
+
|
|
75
|
+
this.session.on(name, handler);
|
|
76
|
+
return handler;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// trigger navigation and poll for handlers to have finished
|
|
81
|
+
await Promise.all([navigate(), waitFor(() => {
|
|
82
|
+
if (this.session.closedReason) {
|
|
83
|
+
throw new Error(this.session.closedReason);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return handlers.every(handler => handler.finished);
|
|
87
|
+
}, Page.TIMEOUT)]);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// remove handlers and modify the error message
|
|
90
|
+
for (let handler of handlers) handler.off();
|
|
91
|
+
|
|
92
|
+
throw Object.assign(error, {
|
|
93
|
+
message: `Navigation failed: ${error.message}`
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.log.debug('Page navigated', this.meta);
|
|
98
|
+
} // Evaluate JS functions within the page's execution context
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async eval(fn, ...args) {
|
|
102
|
+
let fnbody = fn.toString(); // we might have a function shorthand if this fails
|
|
103
|
+
|
|
104
|
+
/* eslint-disable-next-line no-new, no-new-func */
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
new Function(`(${fnbody})`);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
fnbody = fnbody.startsWith('async ') ? fnbody.replace(/^async/, 'async function') : `function ${fnbody}`;
|
|
110
|
+
/* eslint-disable-next-line no-new, no-new-func */
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
new Function(`(${fnbody})`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error('The provided function is not serializable');
|
|
116
|
+
}
|
|
117
|
+
} // wrap the function body with percy helpers
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
fnbody = 'function withPercyHelpers() {\n' + [`return (${fnbody})({ generatePromise, waitFor }, ...arguments);`, `${generatePromise}`, `${waitFor}`].join('\n\n') + '}';
|
|
121
|
+
/* istanbul ignore else: ironic. */
|
|
122
|
+
|
|
123
|
+
if (fnbody.includes('cov_')) {
|
|
124
|
+
// remove coverage statements during testing
|
|
125
|
+
fnbody = fnbody.replace(/cov_.*?(;\n?|,)\s*/g, '');
|
|
126
|
+
} // send the call function command
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
let {
|
|
130
|
+
result,
|
|
131
|
+
exceptionDetails
|
|
132
|
+
} = await this.session.send('Runtime.callFunctionOn', {
|
|
133
|
+
functionDeclaration: fnbody,
|
|
134
|
+
arguments: args.map(value => ({
|
|
135
|
+
value
|
|
136
|
+
})),
|
|
137
|
+
executionContextId: this.contextId,
|
|
138
|
+
returnByValue: true,
|
|
139
|
+
awaitPromise: true,
|
|
140
|
+
userGesture: true
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (exceptionDetails) {
|
|
144
|
+
throw exceptionDetails.exception.description;
|
|
145
|
+
} else {
|
|
146
|
+
return result.value;
|
|
147
|
+
}
|
|
148
|
+
} // Evaluate one or more scripts in succession
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async evaluate(scripts) {
|
|
152
|
+
var _scripts;
|
|
153
|
+
|
|
154
|
+
scripts && (scripts = [].concat(scripts));
|
|
155
|
+
if (!((_scripts = scripts) !== null && _scripts !== void 0 && _scripts.length)) return;
|
|
156
|
+
this.log.debug('Evaluate JavaScript', { ...this.meta,
|
|
157
|
+
scripts
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
for (let script of scripts) {
|
|
161
|
+
if (typeof script === 'string') {
|
|
162
|
+
script = `async eval() {\n${script}\n}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await this.eval(script);
|
|
166
|
+
}
|
|
167
|
+
} // Take a snapshot after waiting for any timeout, waiting for any selector, executing any scripts,
|
|
168
|
+
// and waiting for the network idle
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async snapshot({
|
|
172
|
+
name,
|
|
173
|
+
waitForTimeout,
|
|
174
|
+
waitForSelector,
|
|
175
|
+
execute,
|
|
176
|
+
...options
|
|
177
|
+
}) {
|
|
178
|
+
this.log.debug(`Taking snapshot: ${name}`, this.meta); // wait for any specified timeout
|
|
179
|
+
|
|
180
|
+
if (waitForTimeout) {
|
|
181
|
+
this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta);
|
|
182
|
+
await new Promise(resolve => setTimeout(resolve, waitForTimeout));
|
|
183
|
+
} // wait for any specified selector
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if (waitForSelector) {
|
|
187
|
+
this.log.debug(`Wait for selector: ${waitForSelector}`, this.meta);
|
|
188
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
189
|
+
|
|
190
|
+
await this.eval(function waitForSelector({
|
|
191
|
+
waitFor
|
|
192
|
+
}, selector, timeout) {
|
|
193
|
+
return waitFor(() => !!document.querySelector(selector), timeout).catch(() => Promise.reject(new Error(`Failed to find "${selector}"`)));
|
|
194
|
+
}, waitForSelector, Page.TIMEOUT);
|
|
195
|
+
} // execute any javascript
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
await this.evaluate(typeof execute === 'object' && !Array.isArray(execute) ? execute.beforeSnapshot : execute); // wait for any final network activity before capturing the dom snapshot
|
|
199
|
+
|
|
200
|
+
await this.network.idle(); // inject @percy/dom for serialization by evaluating the file contents which adds a global
|
|
201
|
+
// PercyDOM object that we can later check against
|
|
202
|
+
|
|
203
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
204
|
+
|
|
205
|
+
if (await this.eval(() => !window.PercyDOM)) {
|
|
206
|
+
this.log.debug('Inject @percy/dom', this.meta);
|
|
207
|
+
let script = await fs.promises.readFile(PERCY_DOM, 'utf-8');
|
|
208
|
+
await this.eval(new Function(script));
|
|
209
|
+
/* eslint-disable-line no-new-func */
|
|
210
|
+
} // serialize and capture a DOM snapshot
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
this.log.debug('Serialize DOM', this.meta);
|
|
214
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
215
|
+
|
|
216
|
+
return await this.eval((_, options) => ({
|
|
217
|
+
/* eslint-disable-next-line no-undef */
|
|
218
|
+
dom: PercyDOM.serialize(options),
|
|
219
|
+
url: document.URL
|
|
220
|
+
}), options);
|
|
221
|
+
} // Initialize newly attached pages and iframes with page options
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_handleAttachedToTarget = event => {
|
|
225
|
+
let session = !event ? this.session : this.session.children.get(event.sessionId);
|
|
226
|
+
/* istanbul ignore if: sanity check */
|
|
227
|
+
|
|
228
|
+
if (!session) return;
|
|
229
|
+
let commands = [this.network.watch(session)];
|
|
230
|
+
|
|
231
|
+
if (session.isDocument) {
|
|
232
|
+
session.on('Target.attachedToTarget', this._handleAttachedToTarget);
|
|
233
|
+
commands.push(session.send('Page.enable'), session.send('Page.setLifecycleEventsEnabled', {
|
|
234
|
+
enabled: true
|
|
235
|
+
}), session.send('Security.setIgnoreCertificateErrors', {
|
|
236
|
+
ignore: true
|
|
237
|
+
}), session.send('Emulation.setScriptExecutionDisabled', {
|
|
238
|
+
value: !this.enableJavaScript
|
|
239
|
+
}), session.send('Target.setAutoAttach', {
|
|
240
|
+
waitForDebuggerOnStart: false,
|
|
241
|
+
autoAttach: true,
|
|
242
|
+
flatten: true
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return Promise.all(commands).catch(session._handleClosedError);
|
|
247
|
+
}; // Keep track of the page's execution context id
|
|
248
|
+
|
|
249
|
+
_handleExecutionContextCreated = event => {
|
|
250
|
+
if (this.session.targetId === event.context.auxData.frameId) {
|
|
251
|
+
this.contextId = event.context.id;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
_handleExecutionContextDestroyed = event => {
|
|
255
|
+
/* istanbul ignore next: context cleared is usually called first */
|
|
256
|
+
if (this.contextId === event.executionContextId) {
|
|
257
|
+
this.contextId = null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
_handleExecutionContextsCleared = () => {
|
|
261
|
+
this.contextId = null;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export default Page;
|