document.js

const chainProxy = require('async-chain-proxy')

const {
  TimeoutError,
  WaitTimeoutError,
  EvaluateTimeoutError,
  EvaluateError,
} = require('./error')
const {
  wrapFunctionForEvaluation,
  wrapFunctionForCallFunction,
} = require('./functionToSource')
const {
  escapeHtml,
  escapeNewLine,
  escapeSingleQuote,
} = require('./util')

/**
 * Document class
 *
 * @class
 *
 * @param {Document} [chromy]      Chromy instance
 * @param {CDP}      [client]      CDP instance
 * @param {number}   [nodeId=null]
 */
class Document {
  constructor (chromy, client, nodeId = null) {
    /**
     * Chromy instance
     *
     * @name chromy
     * @type {Document}
     *
     * @memberof Document
     * @instance
     */
    if (chromy) {
      this.chromy = chromy
    } else {
      this.chromy = this
    }
    /**
     * CDP client instance
     *
     * @name client
     * @type {CDP}
     *
     * @memberof Document
     * @instance
     *
     * @see {@link https://www.npmjs.com/package/chrome-remote-interface#event-connect}
     */
    this.client = client
    this.nodeId = nodeId
    this._originalNodeId = nodeId
  }

  chain (options = {}) {
    return chainProxy(this, options)
  }

  async iframe (selector, callback) {
    const rect = await this.getBoundingClientRect(selector)
    if (!rect) {
      return Promise.resolve()
    }
    // to get the the node for location, a position of the node must be in a viewport.
    const originalPageOffset = await this._getPageOffset()
    let doc = null
    try {
      await this.scrollTo(0, rect.top)
      const locationParams = {x: rect.left + 10, y: rect.top + 10}
      const {nodeId: iframeNodeId} = await this.client.DOM.getNodeForLocation(locationParams)
      if (!iframeNodeId) {
        return Promise.resolve()
      }
      doc = new Document(this.chromy, this.client, iframeNodeId)
      doc._activateOnDocumentUpdatedListener()
    } finally {
      // restore scroll potion.
      await this.scrollTo(originalPageOffset.x, originalPageOffset.y)
    }
    return Promise.resolve(callback.apply(this, [doc]))
  }

  async click (expr, inputOptions = {}) {
    const defaults = {waitLoadEvent: false}
    const options = Object.assign({}, defaults, inputOptions)
    let promise = null
    if (options.waitLoadEvent) {
      promise = this.waitLoadEvent()
    }
    let nid = await this._getNodeId()
    let evalExpr = 'document.querySelectorAll(\'' + escapeSingleQuote(expr) + '\').forEach(n => n.click())'
    if (this._originalNodeId) {
      await this._evaluateOnNode(nid, evalExpr)
    } else {
      await this.evaluate(evalExpr)
    }
    if (promise !== null) {
      await promise
    }
  }

  async insert (expr, value) {
    expr = escapeSingleQuote(expr)
    await this.evaluate('document.querySelector(\'' + expr + '\').focus()')
    await this.evaluate('document.querySelector(\'' + expr + '\').value = "' + escapeNewLine(escapeHtml(value)) + '"')
  }

  async check (selector) {
    await this.evaluate('document.querySelectorAll(\'' + escapeSingleQuote(selector) + '\').forEach(n => n.checked = true)')
  }

  async uncheck (selector) {
    await this.evaluate('document.querySelectorAll(\'' + escapeSingleQuote(selector) + '\').forEach(n => n.checked = false)')
  }

  async select (selector, value) {
    let sel = escapeSingleQuote(selector)
    const src = `
      document.querySelectorAll('${sel} option').forEach(n => {
        if (n.value === "${value}") {
          n.selected = true
        }
      })
      `
    await this.evaluate(src)
  }

  async scroll (x, y) {
    return this._evaluateWithReplaces(function () {
      const dx = _1  // eslint-disable-line no-undef
      const dy = _2  // eslint-disable-line no-undef
      window.scrollTo(window.pageXOffset + dx, window.pageYOffset + dy)
    }, {}, {'_1': x, '_2': y})
  }

  async scrollTo (x, y) {
    return this._evaluateWithReplaces(function () {
      window.scrollTo(_1, _2) // eslint-disable-line no-undef
    }, {}, {'_1': x, '_2': y})
  }

  async _getPageOffset () {
    return this.evaluate(_ => {
      return {
        x: window.pageXOffset,
        y: window.pageYOffset,
      }
    })
  }

  /**
   * Evaluates a expression in the browser context
   *
   * @memberof Document
   * @function
   *
   * @param {(function|string)} expr    - JS expression
   *                                      If the expression returns a Promise object,
   *                                      the promise is resolved automatically.
   * @param {(object|array)}    options - Parameter array of `expr` function or
   *                                      Option object
   *
   * @return {Promise}          Returned value of `expr` function
   */
  async evaluate (expr, options = {}) {
    if ((expr instanceof Function) && (options instanceof Array)) {
      options = options.map(function (parameter) {
        return JSON.stringify(JSON.stringify(parameter))
      })
      // eslint-disable-next-line no-new-func
      expr = new Function(`
        return (${expr}).apply(this, Array.from(arguments).concat([${options}].map(function (parameter) {
          return JSON.parse(parameter)
        })))
      `)
      options = {}
    }
    return await this._evaluateWithReplaces(expr, options)
  }

  async _evaluateWithReplaces (expr, options = {}, replaces = {}) {
    let e = null
    if (this._originalNodeId) {
      e = wrapFunctionForCallFunction(expr, replaces)
    } else {
      e = wrapFunctionForEvaluation(expr, replaces)
    }
    try {
      let result = await this._waitFinish(this.chromy.options.evaluateTimeout, async () => {
        if (!this.client) {
          return null
        }
        if (this._originalNodeId) {
          // must call callFunctionOn() for evaluating expression with iframe context.
          const contextNodeId = await this._getNodeId()
          const objectId = await this._getObjectIdFromNodeId(contextNodeId)
          const params = Object.assign({}, options, {objectId: objectId, functionDeclaration: e})
          return await this.client.Runtime.callFunctionOn(params)
        } else {
          return await this.client.Runtime.evaluate({expression: e})
        }
      })
      if (!result || !result.result) {
        return null
      }
      // resolve a promise
      if (result.result.subtype === 'promise') {
        result = await this.client.Runtime.awaitPromise({promiseObjectId: result.result.objectId, returnByValue: true})
        // adjust to after process
        result.result.value = JSON.stringify({
          type: (typeof result.result.value),
          result: JSON.stringify(result.result.value),
        })
      }
      if (result.result.subtype === 'error') {
        throw new EvaluateError('An error has occurred evaluating the script in the browser.' + result.result.description, result.result)
      }
      const resultObject = JSON.parse(result.result.value)
      const type = resultObject.type
      if (type === 'undefined') {
        return undefined
      } else {
        try {
          return JSON.parse(resultObject.result)
        } catch (e) {
          console.log('ERROR', resultObject)
          throw e
        }
      }
    } catch (e) {
      if (e instanceof TimeoutError) {
        throw new EvaluateTimeoutError('evaluate() timeout')
      } else {
        throw e
      }
    }
  }

  // evaluate a function on the specified node context.
  async _evaluateOnNode (nodeId, fn) {
    const objectId = await this._getObjectIdFromNodeId(nodeId)
    const src = fn.toString()
    const functionDeclaration = `function () {
      return (${src})()
    }`
    const params = {
      objectId,
      functionDeclaration,
    }
    await this.client.Runtime.enable()
    await this.client.Runtime.callFunctionOn(params)
  }

  async exists (selector) {
    return this._evaluateWithReplaces(
      _ => { return document.querySelector('?') !== null },
      {}, {'?': escapeSingleQuote(selector)},
    )
  }

  async visible (selector) {
    return this._evaluateWithReplaces(
      _ => {
        let dom = document.querySelector('?')
        return dom !== null && dom.offsetWidth > 0 && dom.offsetHeight > 0
      },
      {}, {'?': escapeSingleQuote(selector)},
    )
  }

  async wait (cond) {
    if ((typeof cond) === 'number') {
      await this.sleep(cond)
    } else if ((typeof cond) === 'function') {
      await this._waitFunction(cond)
    } else {
      await this._waitSelector(cond)
    }
  }

  // wait for func to return true.
  async _waitFunction (func) {
    await this._waitFinish(this.chromy.options.waitTimeout, async () => {
      while (true) {
        const r = await this.evaluate(func)
        if (r) {
          break
        }
        await this.sleep(this.chromy.options.waitFunctionPollingInterval)
      }
    })
  }

  async _waitSelector (selector) {
    let check = null
    let startTime = Date.now()
    await new Promise((resolve, reject) => {
      check = () => {
        setTimeout(async () => {
          try {
            const now = Date.now()
            if (now - startTime > this.chromy.options.waitTimeout) {
              reject(new WaitTimeoutError('wait() timeout', selector))
              return
            }
            const result = await this.exists(selector)
            if (result) {
              resolve(result)
            } else {
              check()
            }
          } catch (e) {
            reject(e)
          }
        }, this.chromy.options.waitFunctionPollingInterval)
      }
      check()
    })
  }

  async _waitFinish (timeout, callback) {
    const start = Date.now()
    let finished = false
    let error = null
    let result = null
    const f = async () => {
      try {
        result = await callback.apply()
        finished = true
        return result
      } catch (e) {
        error = e
        finished = true
      }
    }
    f.apply()
    while (!finished) {
      const now = Date.now()
      if ((now - start) > timeout) {
        throw new TimeoutError('timeout')
      }
      await this.sleep(this.chromy.options.waitFunctionPollingInterval)
    }
    if (error !== null) {
      throw error
    }
    return result
  }

  async type (expr, value) {
    await this.evaluate('document.querySelector(\'' + escapeSingleQuote(expr) + '\').focus()')
    const characters = value.split('')
    for (let i in characters) {
      const c = characters[i]
      await this.client.Input.dispatchKeyEvent({type: 'char', text: c})
      await this.sleep(this.chromy.options.typeInterval)
    }
  }

  async sleep (msec) {
    await new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve()
      }, msec)
    })
  }

  // deprecated
  async getBoundingClientRect (selector) {
    return this.rect(selector)
  }

  async rect (selector) {
    const rect = await this._evaluateWithReplaces(function () {
      let dom = document.querySelector('?')
      if (!dom) {
        return null
      }
      let r = dom.getBoundingClientRect()
      return {top: r.top, left: r.left, width: r.width, height: r.height}
    }, {}, {'?': escapeSingleQuote(selector)})
    if (!rect) {
      return null
    }
    return {
      top: Math.floor(rect.top),
      left: Math.floor(rect.left),
      width: Math.floor(rect.width),
      height: Math.floor(rect.height),
    }
  }

  async rectAll (selector) {
    const rects = await this._evaluateWithReplaces(function () {
      let doms = document.querySelectorAll('?')
      return Array.prototype.map.call(doms, dom => {
        let r = dom.getBoundingClientRect()
        return {top: r.top, left: r.left, width: r.width, height: r.height}
      })
    }, {}, {'?': escapeSingleQuote(selector)})
    return rects.map(rect => {
      return {
        top: Math.floor(rect.top),
        left: Math.floor(rect.left),
        width: Math.floor(rect.width),
        height: Math.floor(rect.height),
      }
    })
  }

  _activateOnDocumentUpdatedListener () {
    this._onDocumentUpdatedListener = () => {
      this.nodeId = null
    }
    this.client.DOM.documentUpdated(this._onDocumentUpdatedListener)
  }

  async _getObjectIdFromNodeId (nodeId) {
    const {object: rObj} = await this.client.DOM.resolveNode({nodeId})
    if (!rObj) {
      return null
    }
    return rObj.objectId
  }

  async _getNodeId () {
    if (!this.nodeId) {
      let {root} = await this.client.DOM.getDocument()
      this.nodeId = root.nodeId
    }
    return this.nodeId
  }

  async _getScreenInfo () {
    return await this.evaluate(function () {
      return {
        devicePixelRatio: window.devicePixelRatio,
        width: document.body.scrollWidth,
        height: document.body.scrollHeight,
        viewportWidth: window.innerWidth,
        viewportHeight: window.innerHeight,
      }
    })
  }

}

module.exports = Document