| /** |
| * Copyright 2019 Google Inc. All rights reserved. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| const {helper, assert, debugError} = require('./helper'); |
| const path = require('path'); |
| |
| function createJSHandle(context, remoteObject) { |
| const frame = context.frame(); |
| if (remoteObject.subtype === 'node' && frame) { |
| const frameManager = frame._frameManager; |
| return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager); |
| } |
| return new JSHandle(context, context._client, remoteObject); |
| } |
| |
| class JSHandle { |
| /** |
| * @param {!Puppeteer.ExecutionContext} context |
| * @param {!Puppeteer.CDPSession} client |
| * @param {!Protocol.Runtime.RemoteObject} remoteObject |
| */ |
| constructor(context, client, remoteObject) { |
| this._context = context; |
| this._client = client; |
| this._remoteObject = remoteObject; |
| this._disposed = false; |
| } |
| |
| /** |
| * @return {!Puppeteer.ExecutionContext} |
| */ |
| executionContext() { |
| return this._context; |
| } |
| |
| /** |
| * @param {Function|String} pageFunction |
| * @param {!Array<*>} args |
| * @return {!Promise<(!Object|undefined)>} |
| */ |
| async evaluate(pageFunction, ...args) { |
| return await this.executionContext().evaluate(pageFunction, this, ...args); |
| } |
| |
| /** |
| * @param {Function|string} pageFunction |
| * @param {!Array<*>} args |
| * @return {!Promise<!Puppeteer.JSHandle>} |
| */ |
| async evaluateHandle(pageFunction, ...args) { |
| return await this.executionContext().evaluateHandle(pageFunction, this, ...args); |
| } |
| |
| /** |
| * @param {string} propertyName |
| * @return {!Promise<?JSHandle>} |
| */ |
| async getProperty(propertyName) { |
| const objectHandle = await this.evaluateHandle((object, propertyName) => { |
| const result = {__proto__: null}; |
| result[propertyName] = object[propertyName]; |
| return result; |
| }, propertyName); |
| const properties = await objectHandle.getProperties(); |
| const result = properties.get(propertyName) || null; |
| await objectHandle.dispose(); |
| return result; |
| } |
| |
| /** |
| * @return {!Promise<!Map<string, !JSHandle>>} |
| */ |
| async getProperties() { |
| const response = await this._client.send('Runtime.getProperties', { |
| objectId: this._remoteObject.objectId, |
| ownProperties: true |
| }); |
| const result = new Map(); |
| for (const property of response.result) { |
| if (!property.enumerable) |
| continue; |
| result.set(property.name, createJSHandle(this._context, property.value)); |
| } |
| return result; |
| } |
| |
| /** |
| * @return {!Promise<?Object>} |
| */ |
| async jsonValue() { |
| if (this._remoteObject.objectId) { |
| const response = await this._client.send('Runtime.callFunctionOn', { |
| functionDeclaration: 'function() { return this; }', |
| objectId: this._remoteObject.objectId, |
| returnByValue: true, |
| awaitPromise: true, |
| }); |
| return helper.valueFromRemoteObject(response.result); |
| } |
| return helper.valueFromRemoteObject(this._remoteObject); |
| } |
| |
| /** |
| * @return {?Puppeteer.ElementHandle} |
| */ |
| asElement() { |
| return null; |
| } |
| |
| async dispose() { |
| if (this._disposed) |
| return; |
| this._disposed = true; |
| await helper.releaseObject(this._client, this._remoteObject); |
| } |
| |
| /** |
| * @override |
| * @return {string} |
| */ |
| toString() { |
| if (this._remoteObject.objectId) { |
| const type = this._remoteObject.subtype || this._remoteObject.type; |
| return 'JSHandle@' + type; |
| } |
| return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); |
| } |
| } |
| |
| class ElementHandle extends JSHandle { |
| /** |
| * @param {!Puppeteer.ExecutionContext} context |
| * @param {!Puppeteer.CDPSession} client |
| * @param {!Protocol.Runtime.RemoteObject} remoteObject |
| * @param {!Puppeteer.Page} page |
| * @param {!Puppeteer.FrameManager} frameManager |
| */ |
| constructor(context, client, remoteObject, page, frameManager) { |
| super(context, client, remoteObject); |
| this._client = client; |
| this._remoteObject = remoteObject; |
| this._page = page; |
| this._frameManager = frameManager; |
| this._disposed = false; |
| } |
| |
| /** |
| * @override |
| * @return {?ElementHandle} |
| */ |
| asElement() { |
| return this; |
| } |
| |
| /** |
| * @return {!Promise<?Puppeteer.Frame>} |
| */ |
| async contentFrame() { |
| const nodeInfo = await this._client.send('DOM.describeNode', { |
| objectId: this._remoteObject.objectId |
| }); |
| if (typeof nodeInfo.node.frameId !== 'string') |
| return null; |
| return this._frameManager.frame(nodeInfo.node.frameId); |
| } |
| |
| async _scrollIntoViewIfNeeded() { |
| const error = await this.evaluate(async(element, pageJavascriptEnabled) => { |
| if (!element.isConnected) |
| return 'Node is detached from document'; |
| if (element.nodeType !== Node.ELEMENT_NODE) |
| return 'Node is not of type HTMLElement'; |
| // force-scroll if page's javascript is disabled. |
| if (!pageJavascriptEnabled) { |
| element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); |
| return false; |
| } |
| const visibleRatio = await new Promise(resolve => { |
| const observer = new IntersectionObserver(entries => { |
| resolve(entries[0].intersectionRatio); |
| observer.disconnect(); |
| }); |
| observer.observe(element); |
| }); |
| if (visibleRatio !== 1.0) |
| element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); |
| return false; |
| }, this._page._javascriptEnabled); |
| if (error) |
| throw new Error(error); |
| } |
| |
| /** |
| * @return {!Promise<!{x: number, y: number}>} |
| */ |
| async _clickablePoint() { |
| const [result, layoutMetrics] = await Promise.all([ |
| this._client.send('DOM.getContentQuads', { |
| objectId: this._remoteObject.objectId |
| }).catch(debugError), |
| this._client.send('Page.getLayoutMetrics'), |
| ]); |
| if (!result || !result.quads.length) |
| throw new Error('Node is either not visible or not an HTMLElement'); |
| // Filter out quads that have too small area to click into. |
| const {clientWidth, clientHeight} = layoutMetrics.layoutViewport; |
| const quads = result.quads.map(quad => this._fromProtocolQuad(quad)).map(quad => this._intersectQuadWithViewport(quad, clientWidth, clientHeight)).filter(quad => computeQuadArea(quad) > 1); |
| if (!quads.length) |
| throw new Error('Node is either not visible or not an HTMLElement'); |
| // Return the middle point of the first quad. |
| const quad = quads[0]; |
| let x = 0; |
| let y = 0; |
| for (const point of quad) { |
| x += point.x; |
| y += point.y; |
| } |
| return { |
| x: x / 4, |
| y: y / 4 |
| }; |
| } |
| |
| /** |
| * @return {!Promise<void|Protocol.DOM.getBoxModelReturnValue>} |
| */ |
| _getBoxModel() { |
| return this._client.send('DOM.getBoxModel', { |
| objectId: this._remoteObject.objectId |
| }).catch(error => debugError(error)); |
| } |
| |
| /** |
| * @param {!Array<number>} quad |
| * @return {!Array<{x: number, y: number}>} |
| */ |
| _fromProtocolQuad(quad) { |
| return [ |
| {x: quad[0], y: quad[1]}, |
| {x: quad[2], y: quad[3]}, |
| {x: quad[4], y: quad[5]}, |
| {x: quad[6], y: quad[7]} |
| ]; |
| } |
| |
| /** |
| * @param {!Array<{x: number, y: number}>} quad |
| * @param {number} width |
| * @param {number} height |
| * @return {!Array<{x: number, y: number}>} |
| */ |
| _intersectQuadWithViewport(quad, width, height) { |
| return quad.map(point => ({ |
| x: Math.min(Math.max(point.x, 0), width), |
| y: Math.min(Math.max(point.y, 0), height), |
| })); |
| } |
| |
| async hover() { |
| await this._scrollIntoViewIfNeeded(); |
| const {x, y} = await this._clickablePoint(); |
| await this._page.mouse.move(x, y); |
| } |
| |
| /** |
| * @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options |
| */ |
| async click(options) { |
| await this._scrollIntoViewIfNeeded(); |
| const {x, y} = await this._clickablePoint(); |
| await this._page.mouse.click(x, y, options); |
| } |
| |
| /** |
| * @param {!Array<string>} values |
| * @return {!Promise<!Array<string>>} |
| */ |
| async select(...values) { |
| for (const value of values) |
| assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"'); |
| return this.evaluate((element, values) => { |
| if (element.nodeName.toLowerCase() !== 'select') |
| throw new Error('Element is not a <select> element.'); |
| |
| const options = Array.from(element.options); |
| element.value = undefined; |
| for (const option of options) { |
| option.selected = values.includes(option.value); |
| if (option.selected && !element.multiple) |
| break; |
| } |
| element.dispatchEvent(new Event('input', { 'bubbles': true })); |
| element.dispatchEvent(new Event('change', { 'bubbles': true })); |
| return options.filter(option => option.selected).map(option => option.value); |
| }, values); |
| } |
| |
| /** |
| * @param {!Array<string>} filePaths |
| */ |
| async uploadFile(...filePaths) { |
| const files = filePaths.map(filePath => path.resolve(filePath)); |
| const objectId = this._remoteObject.objectId; |
| await this._client.send('DOM.setFileInputFiles', { objectId, files }); |
| } |
| |
| async tap() { |
| await this._scrollIntoViewIfNeeded(); |
| const {x, y} = await this._clickablePoint(); |
| await this._page.touchscreen.tap(x, y); |
| } |
| |
| async focus() { |
| await this.evaluate(element => element.focus()); |
| } |
| |
| /** |
| * @param {string} text |
| * @param {{delay: (number|undefined)}=} options |
| */ |
| async type(text, options) { |
| await this.focus(); |
| await this._page.keyboard.type(text, options); |
| } |
| |
| /** |
| * @param {string} key |
| * @param {!{delay?: number, text?: string}=} options |
| */ |
| async press(key, options) { |
| await this.focus(); |
| await this._page.keyboard.press(key, options); |
| } |
| |
| /** |
| * @return {!Promise<?{x: number, y: number, width: number, height: number}>} |
| */ |
| async boundingBox() { |
| const result = await this._getBoxModel(); |
| |
| if (!result) |
| return null; |
| |
| const quad = result.model.border; |
| const x = Math.min(quad[0], quad[2], quad[4], quad[6]); |
| const y = Math.min(quad[1], quad[3], quad[5], quad[7]); |
| const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; |
| const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; |
| |
| return {x, y, width, height}; |
| } |
| |
| /** |
| * @return {!Promise<?BoxModel>} |
| */ |
| async boxModel() { |
| const result = await this._getBoxModel(); |
| |
| if (!result) |
| return null; |
| |
| const {content, padding, border, margin, width, height} = result.model; |
| return { |
| content: this._fromProtocolQuad(content), |
| padding: this._fromProtocolQuad(padding), |
| border: this._fromProtocolQuad(border), |
| margin: this._fromProtocolQuad(margin), |
| width, |
| height |
| }; |
| } |
| |
| /** |
| * |
| * @param {!Object=} options |
| * @returns {!Promise<string|!Buffer>} |
| */ |
| async screenshot(options = {}) { |
| let needsViewportReset = false; |
| |
| let boundingBox = await this.boundingBox(); |
| assert(boundingBox, 'Node is either not visible or not an HTMLElement'); |
| |
| const viewport = this._page.viewport(); |
| |
| if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) { |
| const newViewport = { |
| width: Math.max(viewport.width, Math.ceil(boundingBox.width)), |
| height: Math.max(viewport.height, Math.ceil(boundingBox.height)), |
| }; |
| await this._page.setViewport(Object.assign({}, viewport, newViewport)); |
| |
| needsViewportReset = true; |
| } |
| |
| await this._scrollIntoViewIfNeeded(); |
| |
| boundingBox = await this.boundingBox(); |
| assert(boundingBox, 'Node is either not visible or not an HTMLElement'); |
| assert(boundingBox.width !== 0, 'Node has 0 width.'); |
| assert(boundingBox.height !== 0, 'Node has 0 height.'); |
| |
| const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics'); |
| |
| const clip = Object.assign({}, boundingBox); |
| clip.x += pageX; |
| clip.y += pageY; |
| |
| const imageData = await this._page.screenshot(Object.assign({}, { |
| clip |
| }, options)); |
| |
| if (needsViewportReset) |
| await this._page.setViewport(viewport); |
| |
| return imageData; |
| } |
| |
| /** |
| * @param {string} selector |
| * @return {!Promise<?ElementHandle>} |
| */ |
| async $(selector) { |
| const handle = await this.evaluateHandle( |
| (element, selector) => element.querySelector(selector), |
| selector |
| ); |
| const element = handle.asElement(); |
| if (element) |
| return element; |
| await handle.dispose(); |
| return null; |
| } |
| |
| /** |
| * @param {string} selector |
| * @return {!Promise<!Array<!ElementHandle>>} |
| */ |
| async $$(selector) { |
| const arrayHandle = await this.evaluateHandle( |
| (element, selector) => element.querySelectorAll(selector), |
| selector |
| ); |
| const properties = await arrayHandle.getProperties(); |
| await arrayHandle.dispose(); |
| const result = []; |
| for (const property of properties.values()) { |
| const elementHandle = property.asElement(); |
| if (elementHandle) |
| result.push(elementHandle); |
| } |
| return result; |
| } |
| |
| /** |
| * @param {string} selector |
| * @param {Function|String} pageFunction |
| * @param {!Array<*>} args |
| * @return {!Promise<(!Object|undefined)>} |
| */ |
| async $eval(selector, pageFunction, ...args) { |
| const elementHandle = await this.$(selector); |
| if (!elementHandle) |
| throw new Error(`Error: failed to find element matching selector "${selector}"`); |
| const result = await elementHandle.evaluate(pageFunction, ...args); |
| await elementHandle.dispose(); |
| return result; |
| } |
| |
| /** |
| * @param {string} selector |
| * @param {Function|String} pageFunction |
| * @param {!Array<*>} args |
| * @return {!Promise<(!Object|undefined)>} |
| */ |
| async $$eval(selector, pageFunction, ...args) { |
| const arrayHandle = await this.evaluateHandle( |
| (element, selector) => Array.from(element.querySelectorAll(selector)), |
| selector |
| ); |
| |
| const result = await arrayHandle.evaluate(pageFunction, ...args); |
| await arrayHandle.dispose(); |
| return result; |
| } |
| |
| /** |
| * @param {string} expression |
| * @return {!Promise<!Array<!ElementHandle>>} |
| */ |
| async $x(expression) { |
| const arrayHandle = await this.evaluateHandle( |
| (element, expression) => { |
| const document = element.ownerDocument || element; |
| const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); |
| const array = []; |
| let item; |
| while ((item = iterator.iterateNext())) |
| array.push(item); |
| return array; |
| }, |
| expression |
| ); |
| const properties = await arrayHandle.getProperties(); |
| await arrayHandle.dispose(); |
| const result = []; |
| for (const property of properties.values()) { |
| const elementHandle = property.asElement(); |
| if (elementHandle) |
| result.push(elementHandle); |
| } |
| return result; |
| } |
| |
| /** |
| * @returns {!Promise<boolean>} |
| */ |
| isIntersectingViewport() { |
| return this.evaluate(async element => { |
| const visibleRatio = await new Promise(resolve => { |
| const observer = new IntersectionObserver(entries => { |
| resolve(entries[0].intersectionRatio); |
| observer.disconnect(); |
| }); |
| observer.observe(element); |
| }); |
| return visibleRatio > 0; |
| }); |
| } |
| } |
| |
| function computeQuadArea(quad) { |
| // Compute sum of all directed areas of adjacent triangles |
| // https://en.wikipedia.org/wiki/Polygon#Simple_polygons |
| let area = 0; |
| for (let i = 0; i < quad.length; ++i) { |
| const p1 = quad[i]; |
| const p2 = quad[(i + 1) % quad.length]; |
| area += (p1.x * p2.y - p2.x * p1.y) / 2; |
| } |
| return Math.abs(area); |
| } |
| |
| /** |
| * @typedef {Object} BoxModel |
| * @property {!Array<!{x: number, y: number}>} content |
| * @property {!Array<!{x: number, y: number}>} padding |
| * @property {!Array<!{x: number, y: number}>} border |
| * @property {!Array<!{x: number, y: number}>} margin |
| * @property {number} width |
| * @property {number} height |
| */ |
| |
| module.exports = {createJSHandle, JSHandle, ElementHandle}; |