123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- /**
- * 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');
- 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 isMultiple = await this.evaluate(element => element.multiple);
- assert(filePaths.length <= 1 || isMultiple, 'Multiple file uploads only work with <input type=file multiple>');
- // These imports are only needed for `uploadFile`, so keep them
- // scoped here to avoid paying the cost unnecessarily.
- const path = require('path');
- const mime = require('mime-types');
- const fs = require('fs');
- const readFileAsync = helper.promisify(fs.readFile);
- const promises = filePaths.map(filePath => readFileAsync(filePath));
- const files = [];
- for (let i = 0; i < filePaths.length; i++) {
- const buffer = await promises[i];
- const filePath = path.basename(filePaths[i]);
- const file = {
- name: filePath,
- content: buffer.toString('base64'),
- mimeType: mime.lookup(filePath),
- };
- files.push(file);
- }
- await this.evaluateHandle(async(element, files) => {
- const dt = new DataTransfer();
- for (const item of files) {
- const response = await fetch(`data:${item.mimeType};base64,${item.content}`);
- const file = new File([await response.blob()], item.name);
- dt.items.add(file);
- }
- element.files = dt.files;
- element.dispatchEvent(new Event('input', { bubbles: true }));
- }, 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};
|