UNPKG

19.3 kBJavaScriptView Raw
1/**
2 * Copyright 2017 Google Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17const {helper, assert, debugError} = require('./helper');
18const path = require('path');
19
20const EVALUATION_SCRIPT_URL = '__puppeteer_evaluation_script__';
21const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
22
23function createJSHandle(context, remoteObject) {
24 const frame = context.frame();
25 if (remoteObject.subtype === 'node' && frame) {
26 const frameManager = frame._frameManager;
27 return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager);
28 }
29 return new JSHandle(context, context._client, remoteObject);
30}
31
32class ExecutionContext {
33 /**
34 * @param {!Puppeteer.CDPSession} client
35 * @param {!Protocol.Runtime.ExecutionContextDescription} contextPayload
36 * @param {?Puppeteer.Frame} frame
37 */
38 constructor(client, contextPayload, frame) {
39 this._client = client;
40 this._frame = frame;
41 this._contextId = contextPayload.id;
42 this._isDefault = contextPayload.auxData ? !!contextPayload.auxData['isDefault'] : false;
43 }
44
45 /**
46 * @return {?Puppeteer.Frame}
47 */
48 frame() {
49 return this._frame;
50 }
51
52 /**
53 * @param {Function|string} pageFunction
54 * @param {...*} args
55 * @return {!Promise<(!Object|undefined)>}
56 */
57 async evaluate(pageFunction, ...args) {
58 const handle = await this.evaluateHandle(pageFunction, ...args);
59 const result = await handle.jsonValue().catch(error => {
60 if (error.message.includes('Object reference chain is too long'))
61 return;
62 if (error.message.includes('Object couldn\'t be returned by value'))
63 return;
64 throw error;
65 });
66 await handle.dispose();
67 return result;
68 }
69
70 /**
71 * @param {Function|string} pageFunction
72 * @param {...*} args
73 * @return {!Promise<!JSHandle>}
74 */
75 async evaluateHandle(pageFunction, ...args) {
76 const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
77
78 if (helper.isString(pageFunction)) {
79 const contextId = this._contextId;
80 const expression = /** @type {string} */ (pageFunction);
81 const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix;
82 const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', {
83 expression: expressionWithSourceUrl,
84 contextId,
85 returnByValue: false,
86 awaitPromise: true,
87 userGesture: true
88 }).catch(rewriteError);
89 if (exceptionDetails)
90 throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
91 return createJSHandle(this, remoteObject);
92 }
93
94 if (typeof pageFunction !== 'function')
95 throw new Error('The following is not a function: ' + pageFunction);
96
97 const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
98 functionDeclaration: pageFunction.toString() + '\n' + suffix + '\n',
99 executionContextId: this._contextId,
100 arguments: args.map(convertArgument.bind(this)),
101 returnByValue: false,
102 awaitPromise: true,
103 userGesture: true
104 }).catch(rewriteError);
105 if (exceptionDetails)
106 throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
107 return createJSHandle(this, remoteObject);
108
109 /**
110 * @param {*} arg
111 * @return {*}
112 * @this {ExecutionContext}
113 */
114 function convertArgument(arg) {
115 if (Object.is(arg, -0))
116 return { unserializableValue: '-0' };
117 if (Object.is(arg, Infinity))
118 return { unserializableValue: 'Infinity' };
119 if (Object.is(arg, -Infinity))
120 return { unserializableValue: '-Infinity' };
121 if (Object.is(arg, NaN))
122 return { unserializableValue: 'NaN' };
123 const objectHandle = arg && (arg instanceof JSHandle) ? arg : null;
124 if (objectHandle) {
125 if (objectHandle._context !== this)
126 throw new Error('JSHandles can be evaluated only in the context they were created!');
127 if (objectHandle._disposed)
128 throw new Error('JSHandle is disposed!');
129 if (objectHandle._remoteObject.unserializableValue)
130 return { unserializableValue: objectHandle._remoteObject.unserializableValue };
131 if (!objectHandle._remoteObject.objectId)
132 return { value: objectHandle._remoteObject.value };
133 return { objectId: objectHandle._remoteObject.objectId };
134 }
135 return { value: arg };
136 }
137
138 /**
139 * @param {!Error} error
140 * @return {!Protocol.Runtime.evaluateReturnValue}
141 */
142 function rewriteError(error) {
143 if (error.message.endsWith('Cannot find context with specified id'))
144 throw new Error('Execution context was destroyed, most likely because of a navigation.');
145 throw error;
146 }
147 }
148
149 /**
150 * @param {!JSHandle} prototypeHandle
151 * @return {!Promise<!JSHandle>}
152 */
153 async queryObjects(prototypeHandle) {
154 assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!');
155 assert(prototypeHandle._remoteObject.objectId, 'Prototype JSHandle must not be referencing primitive value');
156 const response = await this._client.send('Runtime.queryObjects', {
157 prototypeObjectId: prototypeHandle._remoteObject.objectId
158 });
159 return createJSHandle(this, response.objects);
160 }
161}
162
163class JSHandle {
164 /**
165 * @param {!ExecutionContext} context
166 * @param {!Puppeteer.CDPSession} client
167 * @param {!Protocol.Runtime.RemoteObject} remoteObject
168 */
169 constructor(context, client, remoteObject) {
170 this._context = context;
171 this._client = client;
172 this._remoteObject = remoteObject;
173 this._disposed = false;
174 }
175
176 /**
177 * @return {!ExecutionContext}
178 */
179 executionContext() {
180 return this._context;
181 }
182
183 /**
184 * @param {string} propertyName
185 * @return {!Promise<?JSHandle>}
186 */
187 async getProperty(propertyName) {
188 const objectHandle = await this._context.evaluateHandle((object, propertyName) => {
189 const result = {__proto__: null};
190 result[propertyName] = object[propertyName];
191 return result;
192 }, this, propertyName);
193 const properties = await objectHandle.getProperties();
194 const result = properties.get(propertyName) || null;
195 await objectHandle.dispose();
196 return result;
197 }
198
199 /**
200 * @return {!Promise<Map<string, !JSHandle>>}
201 */
202 async getProperties() {
203 const response = await this._client.send('Runtime.getProperties', {
204 objectId: this._remoteObject.objectId,
205 ownProperties: true
206 });
207 const result = new Map();
208 for (const property of response.result) {
209 if (!property.enumerable)
210 continue;
211 result.set(property.name, createJSHandle(this._context, property.value));
212 }
213 return result;
214 }
215
216 /**
217 * @return {!Promise<?Object>}
218 */
219 async jsonValue() {
220 if (this._remoteObject.objectId) {
221 const response = await this._client.send('Runtime.callFunctionOn', {
222 functionDeclaration: 'function() { return this; }',
223 objectId: this._remoteObject.objectId,
224 returnByValue: true,
225 awaitPromise: true,
226 });
227 return helper.valueFromRemoteObject(response.result);
228 }
229 return helper.valueFromRemoteObject(this._remoteObject);
230 }
231
232 /**
233 * @return {?Puppeteer.ElementHandle}
234 */
235 asElement() {
236 return null;
237 }
238
239 async dispose() {
240 if (this._disposed)
241 return;
242 this._disposed = true;
243 await helper.releaseObject(this._client, this._remoteObject);
244 }
245
246 /**
247 * @override
248 * @return {string}
249 */
250 toString() {
251 if (this._remoteObject.objectId) {
252 const type = this._remoteObject.subtype || this._remoteObject.type;
253 return 'JSHandle@' + type;
254 }
255 return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject);
256 }
257}
258
259
260class ElementHandle extends JSHandle {
261 /**
262 * @param {!Puppeteer.ExecutionContext} context
263 * @param {!Puppeteer.CDPSession} client
264 * @param {!Protocol.Runtime.RemoteObject} remoteObject
265 * @param {!Puppeteer.Page} page
266 * @param {!Puppeteer.FrameManager} frameManager
267 */
268 constructor(context, client, remoteObject, page, frameManager) {
269 super(context, client, remoteObject);
270 this._client = client;
271 this._remoteObject = remoteObject;
272 this._page = page;
273 this._frameManager = frameManager;
274 this._disposed = false;
275 }
276
277 /**
278 * @override
279 * @return {?ElementHandle}
280 */
281 asElement() {
282 return this;
283 }
284
285 /**
286 * @return {!Promise<?Puppeteer.Frame>}
287 */
288 async contentFrame() {
289 const nodeInfo = await this._client.send('DOM.describeNode', {
290 objectId: this._remoteObject.objectId
291 });
292 if (typeof nodeInfo.node.frameId !== 'string')
293 return null;
294 return this._frameManager.frame(nodeInfo.node.frameId);
295 }
296
297 async _scrollIntoViewIfNeeded() {
298 const error = await this.executionContext().evaluate(async(element, pageJavascriptEnabled) => {
299 if (!element.isConnected)
300 return 'Node is detached from document';
301 if (element.nodeType !== Node.ELEMENT_NODE)
302 return 'Node is not of type HTMLElement';
303 // force-scroll if page's javascript is disabled.
304 if (!pageJavascriptEnabled) {
305 element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
306 return false;
307 }
308 const visibleRatio = await new Promise(resolve => {
309 const observer = new IntersectionObserver(entries => {
310 resolve(entries[0].intersectionRatio);
311 observer.disconnect();
312 });
313 observer.observe(element);
314 });
315 if (visibleRatio !== 1.0)
316 element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
317 return false;
318 }, this, this._page._javascriptEnabled);
319 if (error)
320 throw new Error(error);
321 }
322
323 /**
324 * @return {!Promise<!{x: number, y: number}>}
325 */
326 async _clickablePoint() {
327 const result = await this._client.send('DOM.getContentQuads', {
328 objectId: this._remoteObject.objectId
329 }).catch(debugError);
330 if (!result || !result.quads.length)
331 throw new Error('Node is either not visible or not an HTMLElement');
332 // Filter out quads that have too small area to click into.
333 const quads = result.quads.map(quad => this._fromProtocolQuad(quad)).filter(quad => computeQuadArea(quad) > 1);
334 if (!quads.length)
335 throw new Error('Node is either not visible or not an HTMLElement');
336 // Return the middle point of the first quad.
337 const quad = quads[0];
338 let x = 0;
339 let y = 0;
340 for (const point of quad) {
341 x += point.x;
342 y += point.y;
343 }
344 return {
345 x: x / 4,
346 y: y / 4
347 };
348 }
349
350 /**
351 * @return {!Promise<void|Protocol.DOM.getBoxModelReturnValue>}
352 */
353 _getBoxModel() {
354 return this._client.send('DOM.getBoxModel', {
355 objectId: this._remoteObject.objectId
356 }).catch(error => debugError(error));
357 }
358
359 /**
360 * @param {!Array<number>} quad
361 * @return {!Array<object>}
362 */
363 _fromProtocolQuad(quad) {
364 return [
365 {x: quad[0], y: quad[1]},
366 {x: quad[2], y: quad[3]},
367 {x: quad[4], y: quad[5]},
368 {x: quad[6], y: quad[7]}
369 ];
370 }
371
372 async hover() {
373 await this._scrollIntoViewIfNeeded();
374 const {x, y} = await this._clickablePoint();
375 await this._page.mouse.move(x, y);
376 }
377
378 /**
379 * @param {!Object=} options
380 */
381 async click(options = {}) {
382 await this._scrollIntoViewIfNeeded();
383 const {x, y} = await this._clickablePoint();
384 await this._page.mouse.click(x, y, options);
385 }
386
387 /**
388 * @param {!Array<string>} filePaths
389 * @return {!Promise}
390 */
391 async uploadFile(...filePaths) {
392 const files = filePaths.map(filePath => path.resolve(filePath));
393 const objectId = this._remoteObject.objectId;
394 return this._client.send('DOM.setFileInputFiles', { objectId, files });
395 }
396
397 async tap() {
398 await this._scrollIntoViewIfNeeded();
399 const {x, y} = await this._clickablePoint();
400 await this._page.touchscreen.tap(x, y);
401 }
402
403 async focus() {
404 await this.executionContext().evaluate(element => element.focus(), this);
405 }
406
407 /**
408 * @param {string} text
409 * @param {{delay: (number|undefined)}=} options
410 */
411 async type(text, options) {
412 await this.focus();
413 await this._page.keyboard.type(text, options);
414 }
415
416 /**
417 * @param {string} key
418 * @param {!Object=} options
419 */
420 async press(key, options) {
421 await this.focus();
422 await this._page.keyboard.press(key, options);
423 }
424
425 /**
426 * @return {!Promise<?{x: number, y: number, width: number, height: number}>}
427 */
428 async boundingBox() {
429 const result = await this._getBoxModel();
430
431 if (!result)
432 return null;
433
434 const quad = result.model.border;
435 const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
436 const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
437 const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x;
438 const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y;
439
440 return {x, y, width, height};
441 }
442
443 /**
444 * @return {!Promise<?object>}
445 */
446 async boxModel() {
447 const result = await this._getBoxModel();
448
449 if (!result)
450 return null;
451
452 const {content, padding, border, margin, width, height} = result.model;
453 return {
454 content: this._fromProtocolQuad(content),
455 padding: this._fromProtocolQuad(padding),
456 border: this._fromProtocolQuad(border),
457 margin: this._fromProtocolQuad(margin),
458 width,
459 height
460 };
461 }
462
463 /**
464 *
465 * @param {!Object=} options
466 * @returns {!Promise<Object>}
467 */
468 async screenshot(options = {}) {
469 let needsViewportReset = false;
470
471 let boundingBox = await this.boundingBox();
472 assert(boundingBox, 'Node is either not visible or not an HTMLElement');
473
474 const viewport = this._page.viewport();
475
476 if (boundingBox.width > viewport.width || boundingBox.height > viewport.height) {
477 const newViewport = {
478 width: Math.max(viewport.width, Math.ceil(boundingBox.width)),
479 height: Math.max(viewport.height, Math.ceil(boundingBox.height)),
480 };
481 await this._page.setViewport(Object.assign({}, viewport, newViewport));
482
483 needsViewportReset = true;
484 }
485
486 await this._scrollIntoViewIfNeeded();
487
488 boundingBox = await this.boundingBox();
489 assert(boundingBox, 'Node is either not visible or not an HTMLElement');
490
491 const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics');
492
493 const clip = Object.assign({}, boundingBox);
494 clip.x += pageX;
495 clip.y += pageY;
496
497 const imageData = await this._page.screenshot(Object.assign({}, {
498 clip
499 }, options));
500
501 if (needsViewportReset)
502 await this._page.setViewport(viewport);
503
504 return imageData;
505 }
506
507 /**
508 * @param {string} selector
509 * @return {!Promise<?ElementHandle>}
510 */
511 async $(selector) {
512 const handle = await this.executionContext().evaluateHandle(
513 (element, selector) => element.querySelector(selector),
514 this, selector
515 );
516 const element = handle.asElement();
517 if (element)
518 return element;
519 await handle.dispose();
520 return null;
521 }
522
523 /**
524 * @param {string} selector
525 * @return {!Promise<!Array<!ElementHandle>>}
526 */
527 async $$(selector) {
528 const arrayHandle = await this.executionContext().evaluateHandle(
529 (element, selector) => element.querySelectorAll(selector),
530 this, selector
531 );
532 const properties = await arrayHandle.getProperties();
533 await arrayHandle.dispose();
534 const result = [];
535 for (const property of properties.values()) {
536 const elementHandle = property.asElement();
537 if (elementHandle)
538 result.push(elementHandle);
539 }
540 return result;
541 }
542
543 /**
544 * @param {string} selector
545 * @param {Function|String} pageFunction
546 * @param {!Array<*>} args
547 * @return {!Promise<(!Object|undefined)>}
548 */
549 async $eval(selector, pageFunction, ...args) {
550 const elementHandle = await this.$(selector);
551 if (!elementHandle)
552 throw new Error(`Error: failed to find element matching selector "${selector}"`);
553 const result = await this.executionContext().evaluate(pageFunction, elementHandle, ...args);
554 await elementHandle.dispose();
555 return result;
556 }
557
558 /**
559 * @param {string} selector
560 * @param {Function|String} pageFunction
561 * @param {!Array<*>} args
562 * @return {!Promise<(!Object|undefined)>}
563 */
564 async $$eval(selector, pageFunction, ...args) {
565 const arrayHandle = await this.executionContext().evaluateHandle(
566 (element, selector) => Array.from(element.querySelectorAll(selector)),
567 this, selector
568 );
569
570 const result = await this.executionContext().evaluate(pageFunction, arrayHandle, ...args);
571 await arrayHandle.dispose();
572 return result;
573 }
574
575 /**
576 * @param {string} expression
577 * @return {!Promise<!Array<!ElementHandle>>}
578 */
579 async $x(expression) {
580 const arrayHandle = await this.executionContext().evaluateHandle(
581 (element, expression) => {
582 const document = element.ownerDocument || element;
583 const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
584 const array = [];
585 let item;
586 while ((item = iterator.iterateNext()))
587 array.push(item);
588 return array;
589 },
590 this, expression
591 );
592 const properties = await arrayHandle.getProperties();
593 await arrayHandle.dispose();
594 const result = [];
595 for (const property of properties.values()) {
596 const elementHandle = property.asElement();
597 if (elementHandle)
598 result.push(elementHandle);
599 }
600 return result;
601 }
602
603 /**
604 * @returns {!Promise<boolean>}
605 */
606 isIntersectingViewport() {
607 return this.executionContext().evaluate(async element => {
608 const visibleRatio = await new Promise(resolve => {
609 const observer = new IntersectionObserver(entries => {
610 resolve(entries[0].intersectionRatio);
611 observer.disconnect();
612 });
613 observer.observe(element);
614 });
615 return visibleRatio > 0;
616 }, this);
617 }
618}
619
620function computeQuadArea(quad) {
621 // Compute sum of all directed areas of adjacent triangles
622 // https://en.wikipedia.org/wiki/Polygon#Simple_polygons
623 let area = 0;
624 for (let i = 0; i < quad.length; ++i) {
625 const p1 = quad[i];
626 const p2 = quad[(i + 1) % quad.length];
627 area += (p1.x * p2.y - p2.x * p1.y) / 2;
628 }
629 return area;
630}
631
632helper.tracePublicAPI(ElementHandle);
633helper.tracePublicAPI(JSHandle);
634helper.tracePublicAPI(ExecutionContext);
635
636module.exports = {ExecutionContext, JSHandle, ElementHandle, createJSHandle, EVALUATION_SCRIPT_URL};