// 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:
*
* <option value="foo">Bar</option>
*
* @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:
*
* <option value="foo">Bar</option>
*
* @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:
*
* <option value="foo">Bar</option>
*
* @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 }