5ee21d97e7
In certain cases inline element children might be positioned outside of viewport. In this case, we should intersect all content quads with viewport before we pick one to click into. Fixes #4274.
526 lines
15 KiB
JavaScript
526 lines
15 KiB
JavaScript
/**
|
|
* 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 {string} propertyName
|
|
* @return {!Promise<?JSHandle>}
|
|
*/
|
|
async getProperty(propertyName) {
|
|
const objectHandle = await this._context.evaluateHandle((object, propertyName) => {
|
|
const result = {__proto__: null};
|
|
result[propertyName] = object[propertyName];
|
|
return result;
|
|
}, this, 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.executionContext().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, 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>} 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.executionContext().evaluate(element => element.focus(), this);
|
|
}
|
|
|
|
/**
|
|
* @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.executionContext().evaluateHandle(
|
|
(element, selector) => element.querySelector(selector),
|
|
this, 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.executionContext().evaluateHandle(
|
|
(element, selector) => element.querySelectorAll(selector),
|
|
this, 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 this.executionContext().evaluate(pageFunction, elementHandle, ...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.executionContext().evaluateHandle(
|
|
(element, selector) => Array.from(element.querySelectorAll(selector)),
|
|
this, selector
|
|
);
|
|
|
|
const result = await this.executionContext().evaluate(pageFunction, arrayHandle, ...args);
|
|
await arrayHandle.dispose();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} expression
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
async $x(expression) {
|
|
const arrayHandle = await this.executionContext().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;
|
|
},
|
|
this, 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.executionContext().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;
|
|
}, this);
|
|
}
|
|
}
|
|
|
|
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};
|