lib/JSHandle.js
/* eslint-env node, browser */
const Path = require('path')
const util = require('util')
const { helper, assert, debugError } = require('./helper')
/**
*
* @param {ExecutionContext} context
* @param {Object} remoteObject
* @return {JSHandle|ElementHandle}
*/
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)
}
exports.createJSHandle = createJSHandle
class JSHandle {
/**
* @param {!ExecutionContext} context
* @param {Chrome|CRIConnection|CDPSession|Object} client
* @param {!Object} 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<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 properties = new Map()
const result = response.result
for (let i = 0; i < result.length; i++) {
const property = result[i]
if (!property.enumerable) continue
properties.set(
property.name,
createJSHandle(this._context, property.value)
)
}
return properties
}
/**
* @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 {?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 `${this.constructor.name}@${type}`
}
return `${this.constructor.name}:${helper.valueFromRemoteObject(
this._remoteObject
)}`
}
/** @ignore */
// eslint-disable-next-line space-before-function-paren
[util.inspect.custom](depth, options) {
return this.toString()
}
}
exports.JSHandle = JSHandle
class ElementHandle extends JSHandle {
/**
* @param {!ExecutionContext} context
* @param {Chrome|CRIConnection|CDPSession|Object} client
* @param {!Object} remoteObject
* @param {?Page} page
* @param {?FrameManager} frameManager
*/
constructor (context, client, remoteObject, page, frameManager) {
super(context, client, remoteObject)
/**
* @type {Chrome|CRIConnection|CDPSession|Object}
* @private
*/
this._client = client
/**
* @type {!Object}
* @private
*/
this._remoteObject = remoteObject
/**
* @type {?Page}
* @private
*/
this._page = page
/**
* @type {?FrameManager}
* @private
*/
this._frameManager = frameManager
this._disposed = false
}
/**
* @override
* @return {?ElementHandle}
*/
asElement () {
return this
}
/**
* @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)
}
/**
* @param {string} selector
* @return {Promise<ElementHandle|undefined>}
*/
querySelector (selector) {
return this.$(selector)
}
/**
* @param {string} selector
* @return {Promise<Array<ElementHandle>>}
*/
querySelectorAll (selector) {
return this.$$(selector)
}
/**
* @param {string} selector
* @param {Function|String} pageFunction
* @param {...*} args
* @return {Promise<Object|undefined>}
*/
querySelectorEval (selector, pageFunction, ...args) {
return this.$eval(selector, pageFunction, ...args)
}
/**
* @param {string} selector
* @param {Function|String} pageFunction
* @param {...*} args
* @return {Promise<Object|undefined>}
*/
querySelectorAllEval (selector, pageFunction, ...args) {
return this.$$eval(selector, pageFunction, ...args)
}
/**
* @param {string} expression
* @return {Promise<Array<ElementHandle>>}
*/
xpathQuery (expression) {
return this.$x(expression)
}
/**
* @return {Promise<?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 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 {...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} elemId
* @return {Promise<ElementHandle|undefined>}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById
* @since chrome-remote-interface-extra
*/
async getElementById (elemId) {
const handle = await this.executionContext().evaluateHandle(
(element, id) => element.getElementById(id),
this,
elemId
)
const element = handle.asElement()
if (element) return element
await handle.dispose()
return null
}
/**
* @param {string} selector
* @return {Promise<ElementHandle|undefined>}
*/
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 {...*} 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 {...*} 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
}
/**
* Scrolls the element into view.
* @param {boolean|Object} [scrollHow = {block: 'center', inline: 'center', behavior: 'instant'}] - How to scroll the element into view
* @return {Promise<void>}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
* @since chrome-remote-interface-extra
*/
async scrollIntoView (scrollHow) {
const scrollIntoViewOptions = scrollHow || {
block: 'center',
inline: 'center',
behavior: 'instant'
}
switch (typeof scrollIntoViewOptions) {
case 'boolean':
case 'object':
break
default:
throw new Error(
`The scrollHow param can only be an object or boolean but you supplied ${typeof scrollHow}. `
)
}
await this.executionContext().evaluate(
async (element, scrollIntoViewOpts) => {
if (!element.isConnected) return 'Node is detached from document'
if (
!element.scrollIntoView ||
typeof element.scrollIntoView !== 'function'
) {
return 'Node is not of type Element or does not have the scrollIntoView function'
}
element.scrollIntoView(scrollIntoViewOpts)
return false
},
this,
scrollIntoViewOptions
)
}
/**
* Conditionally scrolls the element into view.
* @param {boolean|Object} [scrollHow = {block: 'center', inline: 'center', behavior: 'instant'}] - How to scroll the element into view
* @return {Promise<void>}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
* @since chrome-remote-interface-extra
* @public
*/
async scrollIntoViewIfNeeded (scrollHow) {
let scrollIntoViewOptions
if (typeof scrollHow === 'boolean') {
scrollIntoViewOptions = scrollHow
} else {
scrollIntoViewOptions = scrollHow || {
block: 'center',
inline: 'center',
behavior: 'instant'
}
}
const error = await this.executionContext().evaluate(
async (element, pageJavascriptEnabled, scrollIntoViewOpts) => {
if (!element.isConnected) return 'Node is detached from document'
if (
!element.scrollIntoView ||
typeof element.scrollIntoView !== 'function'
) {
return 'Node is not of type Element or does not have the scrollIntoView function'
}
// force-scroll if page's javascript is disabled.
if (!pageJavascriptEnabled) {
element.scrollIntoView(scrollIntoViewOpts)
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(scrollIntoViewOpts)
}
return false
},
this,
this._page.javascriptEnabled,
scrollIntoViewOptions
)
if (error) throw new Error(error)
}
/**
* @return {Promise<{x: number, y: number}>}
*/
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 = []
const resultQuads = result.quads
for (let i = 0; i < resultQuads.length; i++) {
const _quads = this._fromProtocolQuad(resultQuads[i])
if (computeQuadArea(_quads) > 1) {
quads.push(_quads)
}
}
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 (let i = 0; i < quad.length; i++) {
const point = quad[i]
x += point.x
y += point.y
}
const clickablePoint = {
x: x / 4,
y: y / 4
}
return clickablePoint
}
/**
* @return {Promise<void|Object>}
*/
_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] }
]
}
}
exports.ElementHandle = ElementHandle
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
*/