Проверка реализации API на ES6: видите ничего страшного?


Я только что закончил писать API, который реализует полный протокол W3C для вебдрайверов.

Это моя первая попытка написать полностью код на ES6, вместе с async/await. Я убрал некоторые функции, вот как они были очень похожи.

Вопросы:

  • Я сделал любой катастрофы на ES6?
  • Может вы увидите что-то явно страшное в коде?
  • У меня есть разнобой.. у меня static get Using () { return USING } и static get KEY () { return KEY }. Они являются константами добавить к классу. Я должен иметь KEY или Key? USING или Using?
  • Все, что я должен посмотреть/улучшить?

Я все уши... спасибо!

var request = require('request-promise-native')
const { spawn } = require('child_process')
var DO = require('deepobject')
const getPort = require('get-port')
var consolelog = require('debug')('webdriver')

const KEY = require('./KEY.js')

const USING = {
  CSS: 'css selector',
  LINK_TEXT: 'link text',
  PARTIAL_LINK_TEXT: 'partial link text',
  TAG_NAME: 'tag name',
  XPATH: 'xpath'
}

function isObject (p) { return typeof p === 'object' && p !== null && !Array.isArray(p) }

function checkRes (res) {
  if (!isObject(res)) throw new Error('Unexpected non-object received from webdriver')
  if (typeof res.value === 'undefined') throw new Error('Missing `value` from object returned by webdriver')
  return res
}

function exec (command, commandOptions) {
  var options = commandOptions || {}

  var proc = spawn(command, options.args || [], {
    env: options.env || process.env,
    stdio: options.stdio || 'ignore'
  })

  proc.on('error', (err) => {
    consolelog(`Could not run ${command}:`, err)
    throw new Error(`Error running the webdriver '${command}'`)
  })

  proc.unref()
  process.once('exit', onProcessExit)

  let result = new Promise(resolve => {
    proc.once('exit', (code, signal) => {
      consolelog(`Process ${command} has exited! Code and signal:`, code, signal)
      proc = null
      process.removeListener('exit', onProcessExit)
      resolve({ code, signal })
    })
  })
  return { result, killCommand }

  function onProcessExit () {
    consolelog(`Process closed, killing ${command}`, killCommand)
    killCommand('SIGTERM')
  }

  function killCommand (signal) {
    consolelog(`killCommand() called! sending ${signal} to ${command}`)
    process.removeListener('exit', onProcessExit)
    if (proc) {
      consolelog(`Sending ${signal} to ${command}`)
      proc.kill(signal)
      proc = null
    }
  }
}

function sleep (ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

class Browser {
  constructor (alwaysMatch = {}, firstMatch = [], root = {}) {
    // Sanity checks. Things can go pretty bad if these are wrong
    if (!isObject(alwaysMatch)) {
      throw new Error('alwaysMatch must be an object')
    }
    if (!Array.isArray(firstMatch)) {
      throw new Error('firstmatch parameter must be an array')
    }
    if (!isObject(root)) {
      throw new Error('root options must be an object')
    }

    this.sessionParameters = {
      capabilities: {
        alwaysMatch: alwaysMatch,
        firstMatch: firstMatch
      }
    }
    // Copy over whatever is specified in `root`
    for (var k in root) {
      if (root.hasOwnProperty(k)) this.sessionParameters[ k ] = root[k]
    }

    // Give it a nice, lowercase name
    this.name = 'browser'
  }
  setAlwaysMatchKey (name, value, force = false) {
    if (force || !this.sessionParameters.capabilities.alwaysMatch.hasOwnProperty(name)) {
      DO.set(this.sessionParameters.capabilities.alwaysMatch, name, value)
    }
  }

  addFirstMatch (name, value, force = false) {
    if (force || !this.sessionParameters.capabilities.firstMatch.indexOf(name) === -1) {
      this.sessionParameters.capabilities.firstMatch.push({ [name]: value })
    }
  }

  setRootKey (name, value, force = false) {
    if (force || !this.sessionParameters.hasOwnProperty(name)) {
      DO.set(this.sessionParameters, name, value)
    }
  }

  getSessionParameters () {
    return this.sessionParameters
  }

  // Options: port, args, env, stdio
  async run (options) {
  }
}

class Chrome extends Browser { // eslint-disable-line no-unused-vars
  constructor (alwaysMatch = {}, firstMatch = [], root = {}, specific = {}) {
    super(...arguments)

    // Give it a nice, lowercase name
    this.name = 'chrome'

    this.setAlwaysMatchKey('chromeOptions.w3c', true, true)

    this.setAlwaysMatchKey('browserName', 'chrome')

    for (var k in specific) {
      if (specific.hasOwnProperty(k)) {
        this.alwaysMatch.chromeOptions[ k ] = specific[ k ]
      }
    }
  }

  run (options) {
    var executable = process.platform === 'win32' ? 'chromedriver.exe' : 'chromedriver'
    options.args.push('--port=' + options.port)
    return exec(executable, options)
  }
}

class Firefox extends Browser { // eslint-disable-line no-unused-vars
  constructor (alwaysMatch = {}, firstMatch = [], root = {}, specific = {}) {
    super(...arguments)

    this.name = 'firefox'
    this.setAlwaysMatchKey('moz:firefoxOptions', {}, true)
    this.setAlwaysMatchKey('browserName', 'firefox')

    for (var k in specific) {
      if (specific.hasOwnProperty(k)) {
        this.alwaysMatch['moz:firefoxOptions'][ k ] = specific[ k ]
      }
    }
  }

  run (options) {
    var executable = process.platform === 'win32' ? 'geckodriver.exe' : 'geckodriver'
    options.args.push('--port=' + options.port)
    return exec(executable, options)
  }
}

class Selenium extends Browser { // eslint-disable-line no-unused-vars
}

class InputDevice {
  constructor (id) {
    this.id = id
  }
}

class Keyboard extends InputDevice {
  constructor (id) {
    super(id)
    this.type = 'key'
  }

  static get UP () {
    return 'keyUp'
  }

  static get DOWN () {
    return 'keyDown'
  }

  tickMethods () {
    return {
      Up: (value) => {
        return {
          type: 'keyUp',
          value
        }
      },

      Down: (value) => {
        return {
          type: 'keyDown',
          value
        }
      }
    }
  }
}

class Pointer extends InputDevice {
  constructor (id, pointerType) {
    super(id)
    this.pointerType = pointerType
    this.type = 'pointer'
  }

  static get Type () {
    return {
      MOUSE: 'mouse',
      PEN: 'pen',
      TOUCH: 'touch'
    }
  }

  static get Origin () {
    return {
      VIEWPORT: 'viewport',
      POINTER: 'pointer'
    }
  }

  tickMethods () {
    return {
      Move: (args) => {
        var origin
        if (!args.origin) {
          origin = Pointer.Origin.VIEWPORT
        } else {
          if (args.origin instanceof Element) {
            origin = {
              'element-6066-11e4-a52e-4f735466cecf': args.origin.id,
              ELEMENT: args.origin.id
            }
          } else {
            origin = args.origin
            if (origin !== Pointer.Origin.VIEWPORT &&
                origin !== Pointer.Origin.POINTER) {
              throw new Error('When using move(), origin must be an element, Pointer.Origin.VIEWPORT or Pointer.Origin.POINTER')
            } else {
            }
          }
        }

        return {
          type: 'pointerMove',
          duration: args.duration || 0,
          origin,
          x: args.x,
          y: args.y
        }
      },
      Down: (button = 0) => {
        return {
          type: 'pointerDown',
          button
        }
      },
      Up: (button = 0) => {
        return {
          type: 'pointerUp',
          button
        }
      },
      Cancel: () => {
        return {
          type: 'pointerCancel'
        }
      }
    }
  }
}

class Actions { // eslint-disable-line no-unused-vars
  constructor (...devices) {
    var self = this
    this.actions = []

    this._compiled = false
    this.compiledActions = []

    if (Object.keys(devices).length) {
      this.devices = devices
    } else {
      this.devices = [
        new Pointer('mouse', Pointer.Type.MOUSE),
        new Keyboard('keyboard')
      ]
    }

    this._tickSetters = {
      get tick () {
        return self.tick
      },
      compile: self.compile.bind(self)
    }
    this.devices.forEach((device) => {
      var deviceTickMethods = device.tickMethods()
      Object.keys(deviceTickMethods).forEach((k) => {
        this._tickSetters[device.id + k] = function (...args) {
          if (!self._currentAction[device.id].virgin) {
            throw new Error(`Action for device ${device.id} already defined (${device.id + k}) for this tick`)
          }
          var res = deviceTickMethods[k].apply(device, args)
          self._currentAction[device.id] = res
          return self._tickSetters
        }
        this._tickSetters['pause'] = function (duration = 0) {
          self._currentAction[device.id] = { type: 'pause', duration }
          return self._tickSetters
        }
      })
    })
  }

  static get KEY () { return KEY }

  compile () {
    if (this._compiled) return
    this.compiledActions = []

    this.devices.forEach((device) => {
      var deviceActions = { actions: [] }
      deviceActions.type = device.type
      deviceActions.id = device.id
      if (device.type === 'pointer') {
        deviceActions.parameters = { pointerType: device.pointerType }
      }

      this.actions.forEach((action) => {
        deviceActions.actions.push(action[ device.id ])
      })
      this.compiledActions.push(deviceActions)
    })
  }

  _setAction (deviceId, action) {
    this._currentAction[ deviceId ] = action
  }

  get tick () {
    this._compiled = false

    var action = {}
    this.devices.forEach((device) => {
      action[ device.id ] = { type: 'pause', duration: 0, virgin: true }
    })
    this.actions.push(action)

    this._currentAction = action

    return this._tickSetters
  }
}

const FindHelpersMixin = (superClass) => class extends superClass {
  findElementCss (value) {
    return this.findElement(Driver.Using.CSS, value)
  }

  findElementLinkText (value) {
    return this.findElement(Driver.Using.LINK_TEXT, value)
  }

  findElementPartialLinkText (value) {
    return this.findElement(Driver.Using.PARTIAL_LINK_TEXT, value)
  }

  findElementTagName (value) {
    return this.findElement(Driver.Using.TAG_NAME, value)
  }

  findElementXpath (value) {
    return this.findElement(Driver.Using.XPATH, value)
  }

  findElementsCss (value) {
    return this.findElements(Driver.Using.CSS, value)
  }

  findElementsLinkText (value) {
    return this.findElements(Driver.Using.LINK_TEXT, value)
  }

  findElementsPartialLinkText (value) {
    return this.findElements(Driver.Using.PARTIAL_LINK_TEXT, value)
  }

  findElementsTagName (value) {
    return this.findElements(Driver.Using.TAG_NAME, value)
  }

  findElementsXpath (value) {
    return this.findElements(Driver.Using.XPATH, value)
  }
}

var Element = class {
  constructor (driver, elObject) {
    var value

    this.driver = driver

    if (isObject(elObject) && elObject.value) value = elObject.value
    else value = elObject

    // Sets the ID. Having `element-XXX` and `ELEMENT` as keys to the object means
    // that it can be used by switchToFrame()
    var idx = 'element-6066-11e4-a52e-4f735466cecf'
    this.id = this.ELEMENT = this[idx] = value[idx]

    // No ID could be find
    if (!this.id) throw new Error('Could not get element ID from element object')
  }

  waitFor (timeout = 0, pollInterval = 0) {
    timeout = timeout || this.driver._defaultPollTimeout
    pollInterval = pollInterval || this.driver._pollInterval
    return Driver.prototype.waitFor.call(this, timeout, pollInterval)
  }

  static get KEY () { return KEY }

  async findElement (using, value) {
    var el = await this._execute('post', `/element/${this.id}/element`, {using, value})
    return new Element(this.driver, el)
  }

  async findElements (using, value) {
    var els = await this._execute('post', `/element/${this.id}/elements`, {using, value})
    if (!Array.isArray(els)) throw new Error('Result from findElements must be an array')
    return els.map((v) => new Element(this.driver, v))
  }

  async isSelected () {
    return !!(await this._execute('get', `/element/${this.id}/selected`))
  }

  getRect () {
    return this._execute('get', `/element/${this.id}/rect`)
  }

  // REMOVED: A FEW MORE, SIMILAR TO getRect() (one liners)

  async isEnabled () {
    return !!(await this._execute('get', `/element/${this.id}/enabled`))
  }


  async sendKeys (text) {
    var value = text.split('')
    await this._execute('post', `/element/${this.id}/value`, { text, value })
    return this
  }

  async takeScreenshot (scroll = true) {
    var data = await this._execute('get', `/element/${this.id}/screenshot`, { scroll })
    return Buffer.from(data, 'base64')
  }

}

var Driver = class {
  constructor (browser, options = {}) {
    this._browser = browser
    this._hostname = options.hostname || '127.0.0.1'
    this._spawn = typeof options.spawn !== 'undefined' ? !!options.spawn : true
    this._webDriverRunning = !this._spawn

    // Parameters passed onto child process
    this._port = options.port
    this._env = isObject(options.env) ? options.env : process.env
    this._stdio = options.stdio || 'ignore'
    this._args = Array.isArray(options.args) ? options.args : []

    this._killCommand = null
    this._commandResult = null

    this._pollInterval = 300
    this._defaultPollTimeout = 10000
    this._urlBase = null
  }

  waitFor (timeout = 0, pollInterval = 0) {
    timeout = timeout || this._defaultPollTimeout
    pollInterval = pollInterval || this._pollInterval
    var self = this
    return new Proxy({}, {
      get (target, name) {
        if (typeof self[name] === 'function') {
          return async function (...args) {
            if (typeof args[args.length - 1] === 'function') {
              var checker = args.pop()
            } else {
              checker = () => true
            }

            var endTime = new Date(Date.now() + timeout)
            var success = false
            var errors = []
            while (true) {
              try {
                consolelog(`Attempting call ${name} with timeout ${timeout} and arguments ${args}`)
                var res = await self[name].apply(self, args)

                if (new Date() > endTime) {
                  consolelog('Call was successful, BUT it was too late. This will fail.')
                  errors.push(new Error('Call successful but too late'))
                  break
                }
                consolelog('Call was successful, checking the result with the provided checker...')
                success = !!checker(res)
                consolelog('Checker returned:', success)
              } catch (e) {
                consolelog('Call resulted in error, checker won\'t be run')
                errors.push(e)
              }
              if (success || new Date() > endTime) {
                consolelog(`Time to get out of the cycle. Success is ${success}`)
                break
              }
              consolelog(`Sleeping for ${pollInterval}, trying again later...`)
              await sleep(pollInterval)
            }

            // If attempt is successful, return res
            if (success) return res
            else {
              var error = new Error('Call was unsuccessful')
              error.errors = errors
              throw error
            }
          }
        } else {
          return self[name]
        }
      } // End of Proxy getter
    }) // End of Proxy
  }

  _ready () {
    return !!(this._sessionId && this._webDriverRunning)
  }

  async startWebDriver () {
    // If it's already connected, nothing to do
    if (this._webDriverRunning) return

    // If spawning is required, do so
    if (this._spawn) {
      // No port: find a free port
      if (!this._port) {
        this._port = await getPort({ host: this._hostname })
      }

      // Options: port, args, env, stdio
      var res = this._browser.run({
        port: this._port,
        args: this._args,
        env: this._env,
        stdio: this._stdio }
      )
      this._killCommand = res.killCommand
      this._commandResult = res.result
    }

    if (!this.port) this.port = '4444'

    this._urlBase = `http://${this._hostname}:${this._port}/session`

    var success = false
    for (var i = 0; i < 10; i++) {
      if (i > 5) {
        consolelog(`Attempt n. ${i} to connect to ${this._hostname}, port ${this._port}... `)
      }
      try {
        await this.status()
        success = true
        break
      } catch (e) {
        await this.sleep(1000)
      }
    }
    if (!success) {
      throw new Error(`Could not connect to the driver`)
    }

    this._webDriverRunning = true
  }

  stopWebDriver (signal = 'SIGTERM') {
    if (this._killCommand) {
      this._killCommand(signal)

      this.killWebDriver('SIGTERM')
      this._webDriverRunning = false
    }
  }

  inspect () {
    return `Driver { ip: ${this.Name}, port: ${this._port} }`
  }

  async newSession () {
    try {
      if (this._sessionId) {
        throw new Error('Session already created. Call deleteSession() first')
      }
      // First of all, try and run the webdriver, if it's not running already
      await this.startWebDriver()

      var value = await this._execute('post', '', this._browser.getSessionParameters())

      // W3C conforming response; check if value is an object containing a `capabilities` object property
      // and a `sessionId` string property
      if (isObject(value) &&
          isObject(value.capabilities) &&
          typeof value.capabilities.browserName === 'string' &&
          typeof value.sessionId === 'string'
      ) {
        this._sessionCapabilities = value.capabilities
        this._sessionId = value.sessionId
      }
      if (!this._sessionId || !this._sessionCapabilities) throw new Error('Could not get sessionId and capabilities out of returned object')

      this._urlBase = `http://${this._hostname}:${this._port}/session/${this._sessionId}`
      return value
    } catch (e) {
      this._sessionId = null
      this._sessionData = {}
      this._urlBase = `http://${this._hostname}:${this._port}/session`
      throw (e)
    }
  }

  async deleteSession () {
    try {
      var value = await this._execute('delete', '')
      this._sessionId = null
      this._sessionData = {}
      this._urlBase = `http://${this._hostname}:${this._port}/session`
      return value
    } catch (e) {
      throw (e)
    }
  }

  async status () {
    var _urlBase = `http://${this._hostname}:${this._port}`
    var res = await request.get({ url: `${_urlBase}/status`, json: true })
    return checkRes(res).value
  }

  async findElement (using, value) {
    var el = await this._execute('post', '/element', {using, value})
    return new Element(this, el)
  }

  async findElements (using, value) {
    var els = await this._execute('post', '/elements', {using, value})
    if (!Array.isArray(els)) throw new Error('Result from findElements must be an array')
    return els.map((v) => new Element(this, v))
  }

  async performActions (actions) {
    actions.compile()
    await this._execute('post', '/actions', { actions: actions.compiledActions })
    return this
  }

  async releaseActions () {
    await this._execute('delete', '/actions')
    return this
  }

  async sleep (ms) {
    return sleep(ms)
  }

  async _execute (method, command, params) {
    if (!(method === 'post' && command === '' && !this._sessionId)) {
      if (!this._ready()) throw new Error('Executing command on non-ready driver')
    }

    var p = { url: `${this._urlBase}${command}` }

    p.json = method === 'post' ? params || {} : true

    var res = await request[method](p)

    return checkRes(res).value
  }

  static get Using () { return USING }

  getTimeouts () {
    return this._execute('get', '/timeouts')
  }

  async navigateTo (url) {
    await this._execute('post', '/url', { url })
    return this
  }
}

// Mixin the find helpers with Driver and Element
Driver = FindHelpersMixin(Driver)
Element = FindHelpersMixin(Element)


102
1
задан 28 января 2018 в 04:01 Источник Поделиться
Комментарии