@percy/core 1.0.0 → 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/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;