lib_select.js

// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you 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.

/*
 * Licensed to the Software Freedom Conservancy (SFC) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The SFC licenses this file
 * to you 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.
 */

'use strict'

const { By } = require('./by')
const error = require('./error')

/**
 * ISelect interface makes a protocol for all kind of select elements (standard html and custom
 * model)
 *
 * @interface
 */
// eslint-disable-next-line no-unused-vars
class ISelect {
  /**
   * @return {!Promise<boolean>} Whether this select element supports selecting multiple options at the same time? This
   * is done by checking the value of the "multiple" attribute.
   */
  isMultiple() {}

  /**
   * @return {!Promise<!Array<!WebElement>>} All options belonging to this select tag
   */
  getOptions() {}

  /**
   * @return {!Promise<!Array<!WebElement>>} All selected options belonging to this select tag
   */
  getAllSelectedOptions() {}

  /**
   * @return {!Promise<!WebElement>} The first selected option in this select tag (or the currently selected option in a
   * normal select)
   */
  getFirstSelectedOption() {}

  /**
   * Select all options that display text matching the argument. That is, when given "Bar" this
   * would select an option like:
   *
   * &lt;option value="foo"&gt;Bar&lt;/option&gt;
   *
   * @param {string} text The visible text to match against
   * @return {Promise<void>}
   */
  selectByVisibleText(text) {} // eslint-disable-line

  /**
   * Select all options that have a value matching the argument. That is, when given "foo" this
   * would select an option like:
   *
   * &lt;option value="foo"&gt;Bar&lt;/option&gt;
   *
   * @param {string} value The value to match against
   * @return {Promise<void>}
   */
  selectByValue(value) {} // eslint-disable-line

  /**
   * Select the option at the given index. This is done by examining the "index" attribute of an
   * element, and not merely by counting.
   *
   * @param {Number} index The option at this index will be selected
   * @return {Promise<void>}
   */
  selectByIndex(index) {} // eslint-disable-line

  /**
   * Clear all selected entries. This is only valid when the SELECT supports multiple selections.
   *
   * @return {Promise<void>}
   */
  deselectAll() {}

  /**
   * Deselect all options that display text matching the argument. That is, when given "Bar" this
   * would deselect an option like:
   *
   * &lt;option value="foo"&gt;Bar&lt;/option&gt;
   *
   * @param {string} text The visible text to match against
   * @return {Promise<void>}
   */
  deselectByVisibleText(text) {} // eslint-disable-line

  /**
   * Deselect all options that have a value matching the argument. That is, when given "foo" this
   * would deselect an option like:
   *
   * @param {string} value The value to match against
   * @return {Promise<void>}
   */
  deselectByValue(value) {} // eslint-disable-line

  /**
   * Deselect the option at the given index. This is done by examining the "index" attribute of an
   * element, and not merely by counting.
   *
   * @param {Number} index The option at this index will be deselected
   * @return {Promise<void>}
   */
  deselectByIndex(index) {} // eslint-disable-line
}

/**
 * @implements ISelect
 */
class Select {
  /**
   * Create an Select Element
   * @param {WebElement} element Select WebElement.
   */
  constructor(element) {
    if (element === null) {
      throw new Error(`Element must not be null. Please provide a valid <select> element.`)
    }

    this.element = element

    this.element.getAttribute('tagName').then(function (tagName) {
      if (tagName.toLowerCase() !== 'select') {
        throw new Error(`Select only works on <select> elements`)
      }
    })

    this.element.getAttribute('multiple').then((multiple) => {
      this.multiple = multiple !== null && multiple !== 'false'
    })
  }

  /**
   *
   * Select option with specified index.
   *
   * <example>
   <select id="selectbox">
   <option value="1">Option 1</option>
   <option value="2">Option 2</option>
   <option value="3">Option 3</option>
   </select>
   const selectBox = await driver.findElement(By.id("selectbox"));
   await selectObject.selectByIndex(1);
   * </example>
   *
   * @param index
   */
  async selectByIndex(index) {
    if (index < 0) {
      throw new Error('Index needs to be 0 or any other positive number')
    }

    let options = await this.element.findElements(By.tagName('option'))

    if (options.length === 0) {
      throw new Error("Select element doesn't contain any option element")
    }

    if (options.length - 1 < index) {
      throw new Error(
        `Option with index "${index}" not found. Select element only contains ${options.length - 1} option elements`,
      )
    }

    for (let option of options) {
      if ((await option.getAttribute('index')) === index.toString()) {
        await this.setSelected(option)
      }
    }
  }

  /**
   *
   * Select option by specific value.
   *
   * <example>
   <select id="selectbox">
   <option value="1">Option 1</option>
   <option value="2">Option 2</option>
   <option value="3">Option 3</option>
   </select>
   const selectBox = await driver.findElement(By.id("selectbox"));
   await selectObject.selectByVisibleText("Option 2");
   * </example>
   *
   *
   * @param {string} value value of option element to be selected
   */
  async selectByValue(value) {
    let matched = false
    let isMulti = await this.isMultiple()

    let options = await this.element.findElements(By.xpath('.//option[@value = ' + escapeQuotes(value) + ']'))

    for (let option of options) {
      await this.setSelected(option)

      if (!isMulti) {
        return
      }
      matched = true
    }

    if (!matched) {
      throw new Error(`Cannot locate option with value: ${value}`)
    }
  }

  /**
   *
   * Select option with displayed text matching the argument.
   *
   * <example>
   <select id="selectbox">
   <option value="1">Option 1</option>
   <option value="2">Option 2</option>
   <option value="3">Option 3</option>
   </select>
   const selectBox = await driver.findElement(By.id("selectbox"));
   await selectObject.selectByVisibleText("Option 2");
   * </example>
   *
   * @param {String|Number} text       text of option element to get selected
   *
   */
  async selectByVisibleText(text) {
    text = typeof text === 'number' ? text.toString() : text

    const xpath = './/option[normalize-space(.) = ' + escapeQuotes(text) + ']'

    const options = await this.element.findElements(By.xpath(xpath))

    for (let option of options) {
      await this.setSelected(option)
      if (!(await this.isMultiple())) {
        return
      }
    }

    let matched = Array.isArray(options) && options.length > 0

    if (!matched && text.includes(' ')) {
      const subStringWithoutSpace = getLongestSubstringWithoutSpace(text)
      let candidates
      if ('' === subStringWithoutSpace) {
        candidates = await this.element.findElements(By.tagName('option'))
      } else {
        const xpath = './/option[contains(., ' + escapeQuotes(subStringWithoutSpace) + ')]'
        candidates = await this.element.findElements(By.xpath(xpath))
      }

      const trimmed = text.trim()

      for (let option of candidates) {
        const optionText = await option.getText()
        if (trimmed === optionText.trim()) {
          await this.setSelected(option)
          if (!(await this.isMultiple())) {
            return
          }
          matched = true
        }
      }
    }

    if (!matched) {
      throw new Error(`Cannot locate option with text: ${text}`)
    }
  }

  /**
   * Returns a list of all options belonging to this select tag
   * @returns {!Promise<!Array<!WebElement>>}
   */
  async getOptions() {
    return await this.element.findElements({ tagName: 'option' })
  }

  /**
   * Returns a boolean value if the select tag is multiple
   * @returns {Promise<boolean>}
   */
  async isMultiple() {
    return this.multiple
  }

  /**
   * Returns a list of all selected options belonging to this select tag
   *
   * @returns {Promise<void>}
   */
  async getAllSelectedOptions() {
    const opts = await this.getOptions()
    const results = []
    for (let options of opts) {
      if (await options.isSelected()) {
        results.push(options)
      }
    }
    return results
  }

  /**
   * Returns first Selected Option
   * @returns {Promise<Element>}
   */
  async getFirstSelectedOption() {
    return (await this.getAllSelectedOptions())[0]
  }

  /**
   * Deselects all selected options
   * @returns {Promise<void>}
   */
  async deselectAll() {
    if (!this.isMultiple()) {
      throw new Error('You may only deselect all options of a multi-select')
    }

    const options = await this.getOptions()

    for (let option of options) {
      if (await option.isSelected()) {
        await option.click()
      }
    }
  }

  /**
   *
   * @param {string|Number}text text of option to deselect
   * @returns {Promise<void>}
   */
  async deselectByVisibleText(text) {
    if (!(await this.isMultiple())) {
      throw new Error('You may only deselect options of a multi-select')
    }

    /**
     * convert value into string
     */
    text = typeof text === 'number' ? text.toString() : text

    const optionElement = await this.element.findElement(
      By.xpath('.//option[normalize-space(.) = ' + escapeQuotes(text) + ']'),
    )
    if (await optionElement.isSelected()) {
      await optionElement.click()
    }
  }

  /**
   *
   * @param {Number} index       index of option element to deselect
   * Deselect the option at the given index.
   * This is done by examining the "index"
   * attribute of an element, and not merely by counting.
   * @returns {Promise<void>}
   */
  async deselectByIndex(index) {
    if (!(await this.isMultiple())) {
      throw new Error('You may only deselect options of a multi-select')
    }

    if (index < 0) {
      throw new Error('Index needs to be 0 or any other positive number')
    }

    let options = await this.element.findElements(By.tagName('option'))

    if (options.length === 0) {
      throw new Error("Select element doesn't contain any option element")
    }

    if (options.length - 1 < index) {
      throw new Error(
        `Option with index "${index}" not found. Select element only contains ${options.length - 1} option elements`,
      )
    }

    for (let option of options) {
      if ((await option.getAttribute('index')) === index.toString()) {
        if (await option.isSelected()) {
          await option.click()
        }
      }
    }
  }

  /**
   *
   * @param {String} value value of an option to deselect
   * @returns {Promise<void>}
   */
  async deselectByValue(value) {
    if (!(await this.isMultiple())) {
      throw new Error('You may only deselect options of a multi-select')
    }

    let matched = false

    let options = await this.element.findElements(By.xpath('.//option[@value = ' + escapeQuotes(value) + ']'))

    if (options.length === 0) {
      throw new Error(`Cannot locate option with value: ${value}`)
    }

    for (let option of options) {
      if (await option.isSelected()) {
        await option.click()
      }
      matched = true
    }

    if (!matched) {
      throw new Error(`Cannot locate option with value: ${value}`)
    }
  }

  async setSelected(option) {
    if (!(await option.isSelected())) {
      if (!(await option.isEnabled())) {
        throw new error.UnsupportedOperationError(`You may not select a disabled option`)
      }
      await option.click()
    }
  }
}

function escapeQuotes(toEscape) {
  if (toEscape.includes(`"`) && toEscape.includes(`'`)) {
    const quoteIsLast = toEscape.lastIndexOf(`"`) === toEscape.length - 1
    const substrings = toEscape.split(`"`)

    // Remove the last element if it's an empty string
    if (substrings[substrings.length - 1] === '') {
      substrings.pop()
    }

    let result = 'concat('

    for (let i = 0; i < substrings.length; i++) {
      result += `"${substrings[i]}"`
      result += i === substrings.length - 1 ? (quoteIsLast ? `, '"')` : `)`) : `, '"', `
    }
    return result
  }

  if (toEscape.includes('"')) {
    return `'${toEscape}'`
  }

  // Otherwise return the quoted string
  return `"${toEscape}"`
}

function getLongestSubstringWithoutSpace(text) {
  let words = text.split(' ')
  let longestString = ''
  for (let word of words) {
    if (word.length > longestString.length) {
      longestString = word
    }
  }
  return longestString
}

module.exports = { Select, escapeQuotes }