WebElementWrapper: Retry WebDriver atomic calls (#40423) (#40990)

* [services/lib/web_element_wrapper] use Generics in replyCall

* [services/lib/web_element_wrapper] remove redundant return types

* [services/lib/web_element_wrapper/] set retryCall timeout to 200 ms

* [services/find] explcitly pass element to have locator===null by default

* use static method to create WebElementWrapper

* missed a couple uses

* move some values to constants

* remove some unnecessary type info
This commit is contained in:
Dmitry Lemeshko 2019-07-12 18:27:40 +02:00 committed by GitHub
parent 39bab79bca
commit f41db0bd42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 245 additions and 125 deletions

View file

@ -185,8 +185,8 @@ export async function BrowserProvider({ getService }: FtrProviderContext) {
* @return {Promise<void>}
*/
public async dragAndDrop(
from: { offset: { x: any; y: any }; location: { _webElement: any } },
to: { offset: { x: any; y: any }; location: { _webElement: any; x: any } }
from: { offset: { x: any; y: any }; location: any },
to: { offset: { x: any; y: any }; location: any }
) {
// tslint:disable-next-line:variable-name
let _from;

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { WebDriver, WebElement } from 'selenium-webdriver';
import { WebDriver, WebElement, By } from 'selenium-webdriver';
import { FtrProviderContext } from '../ftr_provider_context';
import { WebElementWrapper } from './lib/web_element_wrapper';
@ -28,7 +28,6 @@ export async function FindProvider({ getService }: FtrProviderContext) {
const retry = getService('retry');
const driver = webdriver.driver;
const By = webdriver.By;
const until = webdriver.until;
const browserType = webdriver.browserType;
@ -36,9 +35,10 @@ export async function FindProvider({ getService }: FtrProviderContext) {
const defaultFindTimeout = config.get('timeouts.find');
const fixedHeaderHeight = config.get('layout.fixedHeaderHeight');
const wrap = (webElement: WebElement | WebElementWrapper) =>
new WebElementWrapper(
const wrap = (webElement: WebElement | WebElementWrapper, locator: By | null = null) =>
WebElementWrapper.create(
webElement,
locator,
webdriver,
defaultFindTimeout,
fixedHeaderHeight,
@ -46,7 +46,13 @@ export async function FindProvider({ getService }: FtrProviderContext) {
browserType
);
const wrapAll = (webElements: Array<WebElement | WebElementWrapper>) => webElements.map(wrap);
const wrapAll = (webElements: Array<WebElement | WebElementWrapper>) =>
webElements.map(e => wrap(e));
const findAndWrap = async (locator: By, timeout: number): Promise<WebElementWrapper> => {
const webElement = await driver.wait(until.elementLocated(locator), timeout);
return wrap(webElement, locator);
};
class Find {
public currentWait = defaultFindTimeout;
@ -56,7 +62,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.byName('${selector}') with timeout=${timeout}`);
return wrap(await driver.wait(until.elementLocated(By.name(selector)), timeout));
return await findAndWrap(By.name(selector), timeout);
}
public async byCssSelector(
@ -64,7 +70,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.findByCssSelector('${selector}') with timeout=${timeout}`);
return wrap(await driver.wait(until.elementLocated(By.css(selector)), timeout));
return findAndWrap(By.css(selector), timeout);
}
public async byXPath(
@ -72,7 +78,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.byXPath('${selector}') with timeout=${timeout}`);
return wrap(await driver.wait(until.elementLocated(By.xpath(selector)), timeout));
return findAndWrap(By.xpath(selector), timeout);
}
public async byClassName(
@ -80,7 +86,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.findByClassName('${selector}') with timeout=${timeout}`);
return wrap(await driver.wait(until.elementLocated(By.className(selector)), timeout));
return findAndWrap(By.className(selector), timeout);
}
public async activeElement(): Promise<WebElementWrapper> {
@ -107,7 +113,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
});
}
public async filterElementIsDisplayed(elements: any[]) {
public async filterElementIsDisplayed(elements: WebElementWrapper[]) {
if (elements.length === 0) {
return [];
} else {
@ -178,7 +184,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
): Promise<WebElementWrapper | never> {
log.debug(`Find.descendantDisplayedByCssSelector('${selector}')`);
const element = await parentElement._webElement.findElement(By.css(selector));
const descendant = wrap(element);
const descendant = wrap(element, By.css(selector));
const isDisplayed = await descendant.isDisplayed();
if (isDisplayed) {
return descendant;
@ -206,7 +212,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
const element = await this.byLinkText(linkText, timeout);
log.debug(`Wait for element become visible: ${linkText} with timeout=${timeout}`);
await driver.wait(until.elementIsVisible(element._webElement), timeout);
return wrap(element);
return wrap(element, By.linkText(linkText));
}
public async displayedByCssSelector(
@ -217,7 +223,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
const element = await this.byCssSelector(selector, timeout);
log.debug(`Wait for element become visible: ${selector} with timeout=${timeout}`);
await driver.wait(until.elementIsVisible(element._webElement), timeout);
return wrap(element);
return wrap(element, By.css(selector));
}
public async byLinkText(
@ -225,7 +231,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.byLinkText('${selector}') with timeout=${timeout}`);
return wrap(await driver.wait(until.elementLocated(By.linkText(selector)), timeout));
return findAndWrap(By.linkText(selector), timeout);
}
public async byPartialLinkText(
@ -233,9 +239,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
timeout: number = defaultFindTimeout
): Promise<WebElementWrapper> {
log.debug(`Find.byPartialLinkText('${partialLinkText}') with timeout=${timeout}`);
return wrap(
await driver.wait(until.elementLocated(By.partialLinkText(partialLinkText)), timeout)
);
return findAndWrap(By.partialLinkText(partialLinkText), timeout);
}
public async exists(
@ -342,8 +346,7 @@ export async function FindProvider({ getService }: FtrProviderContext) {
log.debug(`Find.byButtonText('${buttonText}') with timeout=${timeout}`);
return await retry.tryForTime(timeout, async () => {
// tslint:disable-next-line:variable-name
const _element =
element instanceof WebElementWrapper ? (element as any)._webElement : element;
const _element = element instanceof WebElementWrapper ? element._webElement : element;
const allButtons = wrapAll(await _element.findElements(By.tagName('button')));
const buttonTexts = await Promise.all(
allButtons.map(async el => {

View file

@ -41,25 +41,52 @@ interface TypeOptions {
charByChar: boolean;
}
const RETRY_CLICK_MAX_ATTEMPTS = 3;
const RETRY_CLICK_RETRY_ON_ERRORS = [
'ElementClickInterceptedError',
'ElementNotInteractableError',
'StaleElementReferenceError',
];
export class WebElementWrapper {
private By: typeof By = this.webDriver.By;
private Keys: IKey = this.webDriver.Key;
private driver: WebDriver = this.webDriver.driver;
public _webElement: WebElement = this.webElement as WebElement;
public LegacyAction: any = this.webDriver.LegacyActionSequence;
public static create(
webElement: WebElement | WebElementWrapper,
locator: By | null,
webDriver: Driver,
timeout: number,
fixedHeaderHeight: number,
logger: ToolingLog,
browserType: string
): WebElementWrapper {
if (webElement instanceof WebElementWrapper) {
return webElement;
}
return new WebElementWrapper(
webElement,
locator,
webDriver,
timeout,
fixedHeaderHeight,
logger,
browserType
);
}
constructor(
private webElement: WebElementWrapper | WebElement,
public _webElement: WebElement,
private locator: By | null,
private webDriver: Driver,
private timeout: number,
private fixedHeaderHeight: number,
private logger: ToolingLog,
private browserType: string
) {
if (webElement instanceof WebElementWrapper) {
return webElement;
}
}
) {}
private async _findWithCustomTimeout(
findFunction: () => Promise<Array<WebElement | WebElementWrapper>>,
@ -75,9 +102,10 @@ export class WebElementWrapper {
return elements;
}
private _wrap(otherWebElement: WebElement | WebElementWrapper) {
return new WebElementWrapper(
private _wrap(otherWebElement: WebElement | WebElementWrapper, locator: By | null = null) {
return WebElementWrapper.create(
otherWebElement,
locator,
this.webDriver,
this.timeout,
this.fixedHeaderHeight,
@ -90,6 +118,32 @@ export class WebElementWrapper {
return otherWebElements.map(e => this._wrap(e));
}
private async retryCall<T>(
fn: (wrapper: this) => T | Promise<T>,
attemptsRemaining: number = RETRY_CLICK_MAX_ATTEMPTS
): Promise<T> {
try {
return await fn(this);
} catch (err) {
if (
!RETRY_CLICK_RETRY_ON_ERRORS.includes(err.name) ||
this.locator === null ||
attemptsRemaining === 0
) {
throw err;
}
this.logger.warning(`WebElementWrapper.${fn.name}: ${err.message}`);
this.logger.debug(
`finding element '${this.locator.toString()}' again, ${attemptsRemaining - 1} attempts left`
);
await delay(200);
this._webElement = await this.driver.findElement(this.locator);
return await this.retryCall(fn, attemptsRemaining - 1);
}
}
/**
* Returns whether or not the element would be visible to an actual user. This means
* that the following types of elements are considered to be not displayed:
@ -104,8 +158,10 @@ export class WebElementWrapper {
*
* @return {Promise<boolean>}
*/
public async isDisplayed(): Promise<boolean> {
return await this._webElement.isDisplayed();
public async isDisplayed() {
return await this.retryCall(async function isDisplayed(wrapper) {
return await wrapper._webElement.isDisplayed();
});
}
/**
@ -114,8 +170,10 @@ export class WebElementWrapper {
*
* @return {Promise<boolean>}
*/
public async isEnabled(): Promise<boolean> {
return await this._webElement.isEnabled();
public async isEnabled() {
return await this.retryCall(async function isEnabled(wrapper) {
return await wrapper._webElement.isEnabled();
});
}
/**
@ -124,8 +182,10 @@ export class WebElementWrapper {
*
* @return {Promise<boolean>}
*/
public async isSelected(): Promise<boolean> {
return await this._webElement.isSelected();
public async isSelected() {
return await this.retryCall(async function isSelected(wrapper) {
return await wrapper._webElement.isSelected();
});
}
/**
@ -134,9 +194,11 @@ export class WebElementWrapper {
*
* @return {Promise<void>}
*/
public async click(): Promise<void> {
await this.scrollIntoViewIfNecessary();
await this._webElement.click();
public async click() {
await this.retryCall(async function click(wrapper) {
await wrapper.scrollIntoViewIfNecessary();
await wrapper._webElement.click();
});
}
/**
@ -148,8 +210,10 @@ export class WebElementWrapper {
*/
async clearValue() {
// https://bugs.chromium.org/p/chromedriver/issues/detail?id=2702
// await this._webElement.clear();
await this.driver.executeScript(`arguments[0].value=''`, this._webElement);
// await wrapper.webElement.clear();
await this.retryCall(async function clearValue(wrapper) {
await wrapper.driver.executeScript(`arguments[0].value=''`, wrapper._webElement);
});
}
/**
@ -157,7 +221,7 @@ export class WebElementWrapper {
* @param {{ charByChar: boolean }} options
* @default { charByChar: false }
*/
async clearValueWithKeyboard(options: TypeOptions = { charByChar: false }): Promise<void> {
async clearValueWithKeyboard(options: TypeOptions = { charByChar: false }) {
if (options.charByChar === true) {
const value = await this.getAttribute('value');
for (let i = 1; i <= value.length; i++) {
@ -167,7 +231,9 @@ export class WebElementWrapper {
} else {
if (this.browserType === Browsers.Chrome) {
// https://bugs.chromium.org/p/chromedriver/issues/detail?id=30
await this.driver.executeScript(`arguments[0].select();`, this._webElement);
await this.retryCall(async function clearValueWithKeyboard(wrapper) {
await wrapper.driver.executeScript(`arguments[0].select();`, wrapper._webElement);
});
await this.pressKeys(this.Keys.BACK_SPACE);
} else {
const selectionKey = this.Keys[process.platform === 'darwin' ? 'COMMAND' : 'CONTROL'];
@ -194,17 +260,18 @@ export class WebElementWrapper {
* @param {charByChar: boolean} options
* @return {Promise<void>}
*/
public async type(
value: string | string[],
options: TypeOptions = { charByChar: false }
): Promise<void> {
public async type(value: string | string[], options: TypeOptions = { charByChar: false }) {
if (options.charByChar) {
for (const char of value) {
await this._webElement.sendKeys(char);
await delay(100);
await this.retryCall(async function type(wrapper) {
await wrapper._webElement.sendKeys(char);
await delay(100);
});
}
} else {
await this._webElement.sendKeys(...value);
await this.retryCall(async function type(wrapper) {
await wrapper._webElement.sendKeys(...value);
});
}
}
@ -218,12 +285,14 @@ export class WebElementWrapper {
public async pressKeys<T extends IKey>(keys: T | T[]): Promise<void>;
public async pressKeys<T extends string>(keys: T | T[]): Promise<void>;
public async pressKeys(keys: string): Promise<void> {
if (Array.isArray(keys)) {
const chord = this.Keys.chord(keys);
await this._webElement.sendKeys(chord);
} else {
await this._webElement.sendKeys(keys);
}
await this.retryCall(async function pressKeys(wrapper) {
if (Array.isArray(keys)) {
const chord = wrapper.Keys.chord(keys);
await wrapper._webElement.sendKeys(chord);
} else {
await wrapper._webElement.sendKeys(keys);
}
});
}
/**
@ -237,8 +306,10 @@ export class WebElementWrapper {
*
* @param {string} name
*/
public async getAttribute(name: string): Promise<string> {
return await this._webElement.getAttribute(name);
public async getAttribute(name: string) {
return await this.retryCall(async function getAttribute(wrapper) {
return await wrapper._webElement.getAttribute(name);
});
}
/**
@ -276,8 +347,10 @@ export class WebElementWrapper {
* @param {string} propertyName
* @return {Promise<string>}
*/
public async getComputedStyle(propertyName: string): Promise<string> {
return await this._webElement.getCssValue(propertyName);
public async getComputedStyle(propertyName: string) {
return await this.retryCall(async function getComputedStyle(wrapper) {
return await wrapper._webElement.getCssValue(propertyName);
});
}
/**
@ -287,8 +360,10 @@ export class WebElementWrapper {
*
* @return {Promise<string>}
*/
public async getVisibleText(): Promise<string> {
return await this._webElement.getText();
public async getVisibleText() {
return await this.retryCall(async function getVisibleText(wrapper) {
return await wrapper._webElement.getText();
});
}
/**
@ -300,7 +375,9 @@ export class WebElementWrapper {
public async getTagName<T extends keyof HTMLElementTagNameMap>(): Promise<T>;
public async getTagName<T extends string>(): Promise<T>;
public async getTagName(): Promise<string> {
return await this._webElement.getTagName();
return await this.retryCall(async function getTagName(wrapper) {
return await wrapper._webElement.getTagName();
});
}
/**
@ -311,7 +388,9 @@ export class WebElementWrapper {
* @return {Promise<{height: number, width: number, x: number, y: number}>}
*/
public async getPosition(): Promise<{ height: number; width: number; x: number; y: number }> {
return await (this._webElement as any).getRect();
return await this.retryCall(async function getPosition(wrapper) {
return await (wrapper._webElement as any).getRect();
});
}
/**
@ -322,7 +401,9 @@ export class WebElementWrapper {
* @return {Promise<{height: number, width: number, x: number, y: number}>}
*/
public async getSize(): Promise<{ height: number; width: number; x: number; y: number }> {
return await (this._webElement as any).getRect();
return await this.retryCall(async function getSize(wrapper) {
return await (wrapper._webElement as any).getRect();
});
}
/**
@ -331,20 +412,22 @@ export class WebElementWrapper {
*
* @return {Promise<void>}
*/
public async moveMouseTo(): Promise<void> {
await this.scrollIntoViewIfNecessary();
if (this.browserType === Browsers.Firefox) {
const actions = (this.driver as any).actions();
await actions.move({ x: 0, y: 0 }).perform();
await actions.move({ x: 10, y: 10, origin: this._webElement }).perform();
} else {
const mouse = (this.driver.actions() as any).mouse();
const actions = (this.driver as any).actions({ bridge: true });
await actions
.pause(mouse)
.move({ origin: this._webElement })
.perform();
}
public async moveMouseTo() {
await this.retryCall(async function moveMouseTo(wrapper) {
await wrapper.scrollIntoViewIfNecessary();
if (wrapper.browserType === Browsers.Firefox) {
const actions = (wrapper.driver as any).actions();
await actions.move({ x: 0, y: 0 }).perform();
await actions.move({ x: 10, y: 10, origin: wrapper._webElement }).perform();
} else {
const mouse = (wrapper.driver.actions() as any).mouse();
const actions = (wrapper.driver as any).actions({ bridge: true });
await actions
.pause(mouse)
.move({ origin: wrapper._webElement })
.perform();
}
});
}
/**
@ -354,8 +437,13 @@ export class WebElementWrapper {
* @param {string} selector
* @return {Promise<WebElementWrapper>}
*/
public async findByCssSelector(selector: string): Promise<WebElementWrapper> {
return this._wrap(await this._webElement.findElement(this.By.css(selector)));
public async findByCssSelector(selector: string) {
return await this.retryCall(async function findByCssSelector(wrapper) {
return wrapper._wrap(
await wrapper._webElement.findElement(wrapper.By.css(selector)),
wrapper.By.css(selector)
);
});
}
/**
@ -367,12 +455,14 @@ export class WebElementWrapper {
* @return {Promise<WebElementWrapper[]>}
*/
public async findAllByCssSelector(selector: string, timeout?: number) {
return this._wrapAll(
await this._findWithCustomTimeout(
async () => await this._webElement.findElements(this.By.css(selector)),
timeout
)
);
return await this.retryCall(async function findAllByCssSelector(wrapper) {
return wrapper._wrapAll(
await wrapper._findWithCustomTimeout(
async () => await wrapper._webElement.findElements(wrapper.By.css(selector)),
timeout
)
);
});
}
/**
@ -382,8 +472,13 @@ export class WebElementWrapper {
* @param {string} className
* @return {Promise<WebElementWrapper>}
*/
public async findByClassName(className: string): Promise<WebElementWrapper> {
return this._wrap(await this._webElement.findElement(this.By.className(className)));
public async findByClassName(className: string) {
return await this.retryCall(async function findByClassName(wrapper) {
return wrapper._wrap(
await wrapper._webElement.findElement(wrapper.By.className(className)),
wrapper.By.className(className)
);
});
}
/**
@ -394,16 +489,15 @@ export class WebElementWrapper {
* @param {number} timeout
* @return {Promise<WebElementWrapper[]>}
*/
public async findAllByClassName(
className: string,
timeout?: number
): Promise<WebElementWrapper[]> {
return this._wrapAll(
await this._findWithCustomTimeout(
async () => await this._webElement.findElements(this.By.className(className)),
timeout
)
);
public async findAllByClassName(className: string, timeout?: number) {
return await this.retryCall(async function findAllByClassName(wrapper) {
return wrapper._wrapAll(
await wrapper._findWithCustomTimeout(
async () => await wrapper._webElement.findElements(wrapper.By.className(className)),
timeout
)
);
});
}
/**
@ -418,7 +512,12 @@ export class WebElementWrapper {
): Promise<WebElementWrapper>;
public async findByTagName<T extends string>(tagName: T): Promise<WebElementWrapper>;
public async findByTagName(tagName: string): Promise<WebElementWrapper> {
return this._wrap(await this._webElement.findElement(this.By.tagName(tagName)));
return await this.retryCall(async function findByTagName(wrapper) {
return wrapper._wrap(
await wrapper._webElement.findElement(wrapper.By.tagName(tagName)),
wrapper.By.tagName(tagName)
);
});
}
/**
@ -438,12 +537,14 @@ export class WebElementWrapper {
timeout?: number
): Promise<WebElementWrapper[]>;
public async findAllByTagName(tagName: string, timeout?: number): Promise<WebElementWrapper[]> {
return this._wrapAll(
await this._findWithCustomTimeout(
async () => await this._webElement.findElements(this.By.tagName(tagName)),
timeout
)
);
return await this.retryCall(async function findAllByTagName(wrapper) {
return wrapper._wrapAll(
await wrapper._findWithCustomTimeout(
async () => await wrapper._webElement.findElements(wrapper.By.tagName(tagName)),
timeout
)
);
});
}
/**
@ -453,8 +554,13 @@ export class WebElementWrapper {
* @param {string} selector
* @return {Promise<WebElementWrapper>}
*/
async findByXpath(selector: string): Promise<WebElementWrapper> {
return this._wrap(await this._webElement.findElement(this.By.xpath(selector)));
async findByXpath(selector: string) {
return await this.retryCall(async function findByXpath(wrapper) {
return wrapper._wrap(
await wrapper._webElement.findElement(wrapper.By.xpath(selector)),
wrapper.By.xpath(selector)
);
});
}
/**
@ -465,41 +571,52 @@ export class WebElementWrapper {
* @param {number} timeout
* @return {Promise<WebElementWrapper[]>}
*/
public async findAllByXpath(selector: string, timeout?: number): Promise<WebElementWrapper[]> {
return this._wrapAll(
await this._findWithCustomTimeout(
async () => await this._webElement.findElements(this.By.xpath(selector)),
timeout
)
);
public async findAllByXpath(selector: string, timeout?: number) {
return await this.retryCall(async function findAllByXpath(wrapper) {
return wrapper._wrapAll(
await wrapper._findWithCustomTimeout(
async () => await wrapper._webElement.findElements(wrapper.By.xpath(selector)),
timeout
)
);
});
}
/**
* Gets the first element inside this element matching the given partial link text.
* https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_WebElement.html#findElement
*
* @param {string} selector
* @param {string} linkText
* @return {Promise<WebElementWrapper[]>}
*/
public async findByPartialLinkText(linkText: string): Promise<WebElementWrapper> {
return await this._wrap(await this._webElement.findElement(this.By.partialLinkText(linkText)));
public async findByPartialLinkText(linkText: string) {
return await this.retryCall(async function findByPartialLinkText(wrapper) {
return wrapper._wrap(
await wrapper._webElement.findElement(wrapper.By.partialLinkText(linkText)),
wrapper.By.partialLinkText(linkText)
);
});
}
/**
* Gets all elements inside this element matching the given partial link text.
* https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_WebElement.html#findElement
*
* @param {string} selector
* @param {string} linkText
* @param {number} timeout
* @return {Promise<WebElementWrapper[]>}
*/
public async findAllByPartialLinkText(linkText: string, timeout?: number) {
return this._wrapAll(
await this._findWithCustomTimeout(
async () => await this._webElement.findElements(this.By.partialLinkText(linkText)),
timeout
)
);
return await this.retryCall(async function findAllByPartialLinkText(
wrapper: WebElementWrapper
) {
return wrapper._wrapAll(
await wrapper._findWithCustomTimeout(
async () => await wrapper._webElement.findElements(wrapper.By.partialLinkText(linkText)),
timeout
)
);
});
}
/**