diff --git a/lib/ExecutionContext.js b/lib/ExecutionContext.js index f0b4af7fb5208..feaadf15ba73b 100644 --- a/lib/ExecutionContext.js +++ b/lib/ExecutionContext.js @@ -14,21 +14,12 @@ * limitations under the License. */ -const {helper, assert, debugError} = require('./helper'); -const path = require('path'); +const {helper, assert} = require('./helper'); +const {createJSHandle, JSHandle} = require('./JSHandle'); const EVALUATION_SCRIPT_URL = '__puppeteer_evaluation_script__'; const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; -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 ExecutionContext { /** * @param {!Puppeteer.CDPSession} client @@ -185,488 +176,6 @@ class ExecutionContext { } } -class JSHandle { - /** - * @param {!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 {!ExecutionContext} - */ - executionContext() { - return this._context; - } - - /** - * @param {string} propertyName - * @return {!Promise} - */ - 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>} - */ - 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} - */ - 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} - */ - 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} - */ - async _clickablePoint() { - const result = await this._client.send('DOM.getContentQuads', { - objectId: this._remoteObject.objectId - }).catch(debugError); - 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 quads = result.quads.map(quad => this._fromProtocolQuad(quad)).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} - */ - _getBoxModel() { - return this._client.send('DOM.getBoxModel', { - objectId: this._remoteObject.objectId - }).catch(error => debugError(error)); - } - - /** - * @param {!Array} 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]} - ]; - } - - 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} 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} - */ - 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} - */ - 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} - */ - 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} - */ - 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>} - */ - 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>} - */ - 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} - */ - 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} content - * @property {!Array} padding - * @property {!Array} border - * @property {!Array} margin - * @property {number} width - * @property {number} height - */ - -helper.tracePublicAPI(ElementHandle); -helper.tracePublicAPI(JSHandle); helper.tracePublicAPI(ExecutionContext); -module.exports = {ExecutionContext, JSHandle, ElementHandle, createJSHandle, EVALUATION_SCRIPT_URL}; +module.exports = {ExecutionContext, EVALUATION_SCRIPT_URL}; diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 09b3c584a2a30..99cd691e45a52 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -19,6 +19,7 @@ const EventEmitter = require('events'); const {helper, assert} = require('./helper'); const {Events} = require('./Events'); const {ExecutionContext} = require('./ExecutionContext'); +const {LifecycleWatcher} = require('./LifecycleWatcher'); const {TimeoutError} = require('./Errors'); const readFileAsync = helper.promisify(fs.readFile); @@ -1142,182 +1143,4 @@ function assertNoLegacyNavigationOptions(options) { assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead'); } -class LifecycleWatcher { - /** - * @param {!FrameManager} frameManager - * @param {!Puppeteer.Frame} frame - * @param {string|!Array} waitUntil - * @param {number} timeout - */ - constructor(frameManager, frame, waitUntil, timeout) { - if (Array.isArray(waitUntil)) - waitUntil = waitUntil.slice(); - else if (typeof waitUntil === 'string') - waitUntil = [waitUntil]; - this._expectedLifecycle = waitUntil.map(value => { - const protocolEvent = puppeteerToProtocolLifecycle[value]; - assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); - return protocolEvent; - }); - - this._frameManager = frameManager; - this._networkManager = frameManager._networkManager; - this._frame = frame; - this._initialLoaderId = frame._loaderId; - this._timeout = timeout; - /** @type {?Puppeteer.Request} */ - this._navigationRequest = null; - this._eventListeners = [ - helper.addEventListener(frameManager._client, Events.CDPSession.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), - helper.addEventListener(this._frameManager, Events.FrameManager.LifecycleEvent, this._checkLifecycleComplete.bind(this)), - helper.addEventListener(this._frameManager, Events.FrameManager.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)), - helper.addEventListener(this._frameManager, Events.FrameManager.FrameDetached, this._onFrameDetached.bind(this)), - helper.addEventListener(this._networkManager, Events.NetworkManager.Request, this._onRequest.bind(this)), - ]; - - this._sameDocumentNavigationPromise = new Promise(fulfill => { - this._sameDocumentNavigationCompleteCallback = fulfill; - }); - - this._lifecyclePromise = new Promise(fulfill => { - this._lifecycleCallback = fulfill; - }); - - this._newDocumentNavigationPromise = new Promise(fulfill => { - this._newDocumentNavigationCompleteCallback = fulfill; - }); - - this._timeoutPromise = this._createTimeoutPromise(); - this._terminationPromise = new Promise(fulfill => { - this._terminationCallback = fulfill; - }); - this._checkLifecycleComplete(); - } - - /** - * @param {!Puppeteer.Request} request - */ - _onRequest(request) { - if (request.frame() !== this._frame || !request.isNavigationRequest()) - return; - this._navigationRequest = request; - } - - /** - * @param {!Puppeteer.Frame} frame - */ - _onFrameDetached(frame) { - if (this._frame === frame) { - this._terminationCallback.call(null, new Error('Navigating frame was detached')); - return; - } - this._checkLifecycleComplete(); - } - - /** - * @return {?Puppeteer.Response} - */ - navigationResponse() { - return this._navigationRequest ? this._navigationRequest.response() : null; - } - - /** - * @param {!Error} error - */ - _terminate(error) { - this._terminationCallback.call(null, error); - } - - /** - * @return {!Promise} - */ - sameDocumentNavigationPromise() { - return this._sameDocumentNavigationPromise; - } - - /** - * @return {!Promise} - */ - newDocumentNavigationPromise() { - return this._newDocumentNavigationPromise; - } - - /** - * @return {!Promise} - */ - lifecyclePromise() { - return this._lifecyclePromise; - } - - /** - * @return {!Promise} - */ - timeoutOrTerminationPromise() { - return Promise.race([this._timeoutPromise, this._terminationPromise]); - } - - /** - * @return {!Promise} - */ - _createTimeoutPromise() { - if (!this._timeout) - return new Promise(() => {}); - const errorMessage = 'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded'; - return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout)) - .then(() => new TimeoutError(errorMessage)); - } - - /** - * @param {!Puppeteer.Frame} frame - */ - _navigatedWithinDocument(frame) { - if (frame !== this._frame) - return; - this._hasSameDocumentNavigation = true; - this._checkLifecycleComplete(); - } - - _checkLifecycleComplete() { - // We expect navigation to commit. - if (!checkLifecycle(this._frame, this._expectedLifecycle)) - return; - this._lifecycleCallback(); - if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) - return; - if (this._hasSameDocumentNavigation) - this._sameDocumentNavigationCompleteCallback(); - if (this._frame._loaderId !== this._initialLoaderId) - this._newDocumentNavigationCompleteCallback(); - - /** - * @param {!Puppeteer.Frame} frame - * @param {!Array} expectedLifecycle - * @return {boolean} - */ - function checkLifecycle(frame, expectedLifecycle) { - for (const event of expectedLifecycle) { - if (!frame._lifecycleEvents.has(event)) - return false; - } - for (const child of frame.childFrames()) { - if (!checkLifecycle(child, expectedLifecycle)) - return false; - } - return true; - } - } - - dispose() { - helper.removeEventListeners(this._eventListeners); - clearTimeout(this._maximumTimer); - } -} - -const puppeteerToProtocolLifecycle = { - 'load': 'load', - 'domcontentloaded': 'DOMContentLoaded', - 'networkidle0': 'networkIdle', - 'networkidle2': 'networkAlmostIdle', -}; - module.exports = {FrameManager, Frame}; diff --git a/lib/JSHandle.js b/lib/JSHandle.js new file mode 100644 index 0000000000000..226b1ed06761e --- /dev/null +++ b/lib/JSHandle.js @@ -0,0 +1,511 @@ +/** + * 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} + */ + 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>} + */ + 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} + */ + 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); + } +} + +helper.tracePublicAPI(JSHandle); + +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} + */ + 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} + */ + async _clickablePoint() { + const result = await this._client.send('DOM.getContentQuads', { + objectId: this._remoteObject.objectId + }).catch(debugError); + 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 quads = result.quads.map(quad => this._fromProtocolQuad(quad)).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} + */ + _getBoxModel() { + return this._client.send('DOM.getBoxModel', { + objectId: this._remoteObject.objectId + }).catch(error => debugError(error)); + } + + /** + * @param {!Array} 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]} + ]; + } + + 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} 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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>} + */ + 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>} + */ + 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} + */ + 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} content + * @property {!Array} padding + * @property {!Array} border + * @property {!Array} margin + * @property {number} width + * @property {number} height + */ + +helper.tracePublicAPI(ElementHandle); +module.exports = {createJSHandle, JSHandle, ElementHandle}; diff --git a/lib/LifecycleWatcher.js b/lib/LifecycleWatcher.js new file mode 100644 index 0000000000000..8d183ca171ca8 --- /dev/null +++ b/lib/LifecycleWatcher.js @@ -0,0 +1,199 @@ +/** + * 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} = require('./helper'); +const {Events} = require('./Events'); +const {TimeoutError} = require('./Errors'); + +class LifecycleWatcher { + /** + * @param {!Puppeteer.FrameManager} frameManager + * @param {!Puppeteer.Frame} frame + * @param {string|!Array} waitUntil + * @param {number} timeout + */ + constructor(frameManager, frame, waitUntil, timeout) { + if (Array.isArray(waitUntil)) + waitUntil = waitUntil.slice(); + else if (typeof waitUntil === 'string') + waitUntil = [waitUntil]; + this._expectedLifecycle = waitUntil.map(value => { + const protocolEvent = puppeteerToProtocolLifecycle[value]; + assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); + return protocolEvent; + }); + + this._frameManager = frameManager; + this._networkManager = frameManager._networkManager; + this._frame = frame; + this._initialLoaderId = frame._loaderId; + this._timeout = timeout; + /** @type {?Puppeteer.Request} */ + this._navigationRequest = null; + this._eventListeners = [ + helper.addEventListener(frameManager._client, Events.CDPSession.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), + helper.addEventListener(this._frameManager, Events.FrameManager.LifecycleEvent, this._checkLifecycleComplete.bind(this)), + helper.addEventListener(this._frameManager, Events.FrameManager.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)), + helper.addEventListener(this._frameManager, Events.FrameManager.FrameDetached, this._onFrameDetached.bind(this)), + helper.addEventListener(this._networkManager, Events.NetworkManager.Request, this._onRequest.bind(this)), + ]; + + this._sameDocumentNavigationPromise = new Promise(fulfill => { + this._sameDocumentNavigationCompleteCallback = fulfill; + }); + + this._lifecyclePromise = new Promise(fulfill => { + this._lifecycleCallback = fulfill; + }); + + this._newDocumentNavigationPromise = new Promise(fulfill => { + this._newDocumentNavigationCompleteCallback = fulfill; + }); + + this._timeoutPromise = this._createTimeoutPromise(); + this._terminationPromise = new Promise(fulfill => { + this._terminationCallback = fulfill; + }); + this._checkLifecycleComplete(); + } + + /** + * @param {!Puppeteer.Request} request + */ + _onRequest(request) { + if (request.frame() !== this._frame || !request.isNavigationRequest()) + return; + this._navigationRequest = request; + } + + /** + * @param {!Puppeteer.Frame} frame + */ + _onFrameDetached(frame) { + if (this._frame === frame) { + this._terminationCallback.call(null, new Error('Navigating frame was detached')); + return; + } + this._checkLifecycleComplete(); + } + + /** + * @return {?Puppeteer.Response} + */ + navigationResponse() { + return this._navigationRequest ? this._navigationRequest.response() : null; + } + + /** + * @param {!Error} error + */ + _terminate(error) { + this._terminationCallback.call(null, error); + } + + /** + * @return {!Promise} + */ + sameDocumentNavigationPromise() { + return this._sameDocumentNavigationPromise; + } + + /** + * @return {!Promise} + */ + newDocumentNavigationPromise() { + return this._newDocumentNavigationPromise; + } + + /** + * @return {!Promise} + */ + lifecyclePromise() { + return this._lifecyclePromise; + } + + /** + * @return {!Promise} + */ + timeoutOrTerminationPromise() { + return Promise.race([this._timeoutPromise, this._terminationPromise]); + } + + /** + * @return {!Promise} + */ + _createTimeoutPromise() { + if (!this._timeout) + return new Promise(() => {}); + const errorMessage = 'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded'; + return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout)) + .then(() => new TimeoutError(errorMessage)); + } + + /** + * @param {!Puppeteer.Frame} frame + */ + _navigatedWithinDocument(frame) { + if (frame !== this._frame) + return; + this._hasSameDocumentNavigation = true; + this._checkLifecycleComplete(); + } + + _checkLifecycleComplete() { + // We expect navigation to commit. + if (!checkLifecycle(this._frame, this._expectedLifecycle)) + return; + this._lifecycleCallback(); + if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) + return; + if (this._hasSameDocumentNavigation) + this._sameDocumentNavigationCompleteCallback(); + if (this._frame._loaderId !== this._initialLoaderId) + this._newDocumentNavigationCompleteCallback(); + + /** + * @param {!Puppeteer.Frame} frame + * @param {!Array} expectedLifecycle + * @return {boolean} + */ + function checkLifecycle(frame, expectedLifecycle) { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) + return false; + } + for (const child of frame.childFrames()) { + if (!checkLifecycle(child, expectedLifecycle)) + return false; + } + return true; + } + } + + dispose() { + helper.removeEventListeners(this._eventListeners); + clearTimeout(this._maximumTimer); + } +} + +const puppeteerToProtocolLifecycle = { + 'load': 'load', + 'domcontentloaded': 'DOMContentLoaded', + 'networkidle0': 'networkIdle', + 'networkidle2': 'networkAlmostIdle', +}; + +module.exports = {LifecycleWatcher}; diff --git a/lib/Page.js b/lib/Page.js index df809dbbb07dd..a92cb1b5f6dd9 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -27,7 +27,7 @@ const Tracing = require('./Tracing'); const {helper, debugError, assert} = require('./helper'); const {Coverage} = require('./Coverage'); const {Worker} = require('./Worker'); -const {createJSHandle} = require('./ExecutionContext'); +const {createJSHandle} = require('./JSHandle'); const {Accessibility} = require('./Accessibility'); const writeFileAsync = helper.promisify(fs.writeFile); diff --git a/lib/Worker.js b/lib/Worker.js index 5ac00617aa97c..1161e6b844fa1 100644 --- a/lib/Worker.js +++ b/lib/Worker.js @@ -15,7 +15,8 @@ */ const EventEmitter = require('events'); const {helper, debugError} = require('./helper'); -const {ExecutionContext, JSHandle} = require('./ExecutionContext'); +const {ExecutionContext} = require('./ExecutionContext'); +const {JSHandle} = require('./JSHandle'); class Worker extends EventEmitter { /** diff --git a/lib/externs.d.ts b/lib/externs.d.ts index 11d47bc0c7c1b..956278f5ba2a9 100644 --- a/lib/externs.d.ts +++ b/lib/externs.d.ts @@ -5,7 +5,8 @@ import {Page as RealPage} from './Page.js'; import {TaskQueue as RealTaskQueue} from './TaskQueue.js'; import {Mouse as RealMouse, Keyboard as RealKeyboard, Touchscreen as RealTouchscreen} from './Input.js'; import {Frame as RealFrame, FrameManager as RealFrameManager} from './FrameManager.js'; -import {JSHandle as RealJSHandle, ElementHandle as RealElementHandle, ExecutionContext as RealExecutionContext} from './ExecutionContext.js'; +import {JSHandle as RealJSHandle, ElementHandle as RealElementHandle} from './JSHandle.js'; +import {ExecutionContext as RealExecutionContext} from './ExecutionContext.js'; import { NetworkManager as RealNetworkManager, Request as RealRequest, Response as RealResponse } from './NetworkManager.js'; import * as child_process from 'child_process'; declare global {