Home Reference Source

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
 */