@mayahq/robula-plus
Version:
Robula+ is an algorithm to generate robust XPath-based locators, that are likely to work correctly with new releases of a web application. Robula+ reduces the locators' fragility on average by 90% w.r.t. absolute locators and by 63% w.r.t. Selenium IDE lo
338 lines (337 loc) • 13.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RobulaPlusOptions = exports.XPath = exports.RobulaPlus = void 0;
/**
* Main class, containing the Algorithm.
*
* @remarks For more information on how the algorithm works, please refer to:
* Maurizio Leotta, Andrea Stocco, Filippo Ricca, Paolo Tonella. ROBULA+:
* An Algorithm for Generating Robust XPath Locators for Web Testing. Journal
* of Software: Evolution and Process (JSEP), Volume 28, Issue 3, pp.177–204.
* John Wiley & Sons, 2016.
* https://doi.org/10.1002/smr.1771
*
* @param options - (optional) algorithm options.
*/
class RobulaPlus {
constructor(options) {
this.attributePriorizationList = ['mayaId', 'aria-label', 'name', 'title', 'alt', 'value', 'data-tooltip', 'class'];
this.attributeBlackList = [
'href',
'src',
'onclick',
'onload',
'tabindex',
'width',
'height',
'size',
'maxlength',
];
if (options) {
this.attributePriorizationList = options.attributePriorizationList;
this.attributeBlackList = options.attributeBlackList;
}
}
/**
* Returns an optimized robust XPath locator string.
*
* @param element - The desired element.
* @param document - The document to analyse, that contains the desired element.
*
* @returns - A robust xPath locator string, describing the desired element.
*/
getRobustXPath(element, document) {
try {
if (!document.body.contains(element)) {
throw new Error('Document does not contain given element!');
}
const xPathList = [new XPath('//*')];
let stop = false;
while (xPathList.length > 0 && !stop) {
// There's a bug in this robula+ implementation which we don't have the
// bandwidth to fix, so here's a cheap fix that prevents infinite loops.
if (xPathList.length > 50) {
stop = true;
}
const xPath = xPathList.shift();
let temp = [];
temp = temp.concat(this.transfConvertStar(xPath, element));
temp = temp.concat(this.transfAddId(xPath, element));
// temp = temp.concat(this.transfAddText(xPath, element)); // Leads to stupid xpaths
temp = temp.concat(this.transfAddAttribute(xPath, element));
temp = temp.concat(this.transfAddAttributeSet(xPath, element));
temp = temp.concat(this.transfAddPosition(xPath, element));
temp = temp.concat(this.transfAddLevel(xPath, element));
temp = [...new Set(temp)]; // removes duplicates
for (const x of temp) {
if (this.uniquelyLocate(x.getValue(), element, document)) {
return x.getValue();
}
xPathList.push(x);
}
}
throw new Error('Internal Error: xPathList.shift returns undefined');
}
catch (e) {
return "failed";
}
}
/**
* Returns an element in the given document located by the given xPath locator.
*
* @param xPath - A xPath string, describing the desired element.
* @param document - The document to analyse, that contains the desired element.
*
* @returns - The first maching Element located.
*/
getElementByXPath(xPath, document) {
return document.evaluate(xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
.singleNodeValue;
}
/**
* Returns, wheater an xPath describes only the given element.
*
* @param xPath - A xPath string, describing the desired element.
* @param element - The desired element.
* @param document - The document to analyse, that contains the desired element.
*
* @returns - True, if the xPath describes only the desired element.
*/
uniquelyLocate(xPath, element, document) {
try {
const nodesSnapshot = document.evaluate(xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
return nodesSnapshot.snapshotLength === 1 && nodesSnapshot.snapshotItem(0) === element;
}
catch (e) {
return false;
}
}
transfConvertStar(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (xPath.startsWith('//*')) {
output.push(new XPath('//' + ancestor.tagName.toLowerCase() + xPath.substring(3)));
}
return output;
}
transfAddId(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (ancestor.id && !xPath.headHasAnyPredicates()) {
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(`[='${ancestor.id}']`);
output.push(newXPath);
}
return output;
}
transfAddText(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (ancestor.textContent && !xPath.headHasPositionPredicate() && !xPath.headHasTextPredicate()) {
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(`[contains(text(),'${ancestor.textContent}')]`);
output.push(newXPath);
}
return output;
}
transfAddAttribute(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (!xPath.headHasAnyPredicates()) {
// add priority attributes to output
for (const priorityAttribute of this.attributePriorizationList) {
for (const attribute of ancestor.attributes) {
if (attribute.name === priorityAttribute) {
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(`[@${attribute.name}='${attribute.value}']`);
output.push(newXPath);
break;
}
}
}
// append all other non-blacklist attributes to output
for (const attribute of ancestor.attributes) {
if (!this.attributeBlackList.includes(attribute.name) &&
!this.attributePriorizationList.includes(attribute.name)) {
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(`[@${attribute.name}='${attribute.value}']`);
output.push(newXPath);
}
}
}
return output;
}
transfAddAttributeSet(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (!xPath.headHasAnyPredicates()) {
// add id to attributePriorizationList
this.attributePriorizationList.unshift('id');
let attributes = [...ancestor.attributes];
// remove black list attributes
attributes = attributes.filter(attribute => !this.attributeBlackList.includes(attribute.name));
// generate power set
let attributePowerSet = this.generatePowerSet(attributes);
// remove sets with cardinality < 2
attributePowerSet = attributePowerSet.filter(attributeSet => attributeSet.length >= 2);
// sort elements inside each powerset
for (const attributeSet of attributePowerSet) {
attributeSet.sort(this.elementCompareFunction.bind(this));
}
// sort attributePowerSet
attributePowerSet.sort((set1, set2) => {
if (set1.length < set2.length) {
return -1;
}
if (set1.length > set2.length) {
return 1;
}
for (let i = 0; i < set1.length; i++) {
if (set1[i] !== set2[i]) {
return this.elementCompareFunction(set1[i], set2[i]);
}
}
return 0;
});
// remove id from attributePriorizationList
this.attributePriorizationList.shift();
// convert to predicate
for (const attributeSet of attributePowerSet) {
let predicate = `[@${attributeSet[0].name}='${attributeSet[0].value}'`;
for (let i = 1; i < attributeSet.length; i++) {
predicate += ` and @${attributeSet[i].name}='${attributeSet[i].value}'`;
}
predicate += ']';
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(predicate);
output.push(newXPath);
}
}
return output;
}
transfAddPosition(xPath, element) {
const output = [];
const ancestor = this.getAncestor(element, xPath.getLength() - 1);
if (!xPath.headHasPositionPredicate()) {
let position = 1;
if (xPath.startsWith('//*')) {
position = Array.from(ancestor.parentNode.children).indexOf(ancestor) + 1;
}
else {
for (const child of ancestor.parentNode.children) {
if (ancestor === child) {
break;
}
if (ancestor.tagName === child.tagName) {
position++;
}
}
}
const newXPath = new XPath(xPath.getValue());
newXPath.addPredicateToHead(`[${position}]`);
output.push(newXPath);
}
return output;
}
transfAddLevel(xPath, element) {
const output = [];
if (xPath.getLength() - 1 < this.getAncestorCount(element)) {
output.push(new XPath('//*' + xPath.substring(1)));
}
return output;
}
generatePowerSet(input) {
return input.reduce((subsets, value) => subsets.concat(subsets.map((set) => [value, ...set])), [[]]);
}
elementCompareFunction(attr1, attr2) {
for (const element of this.attributePriorizationList) {
if (element === attr1.name) {
return -1;
}
if (element === attr2.name) {
return 1;
}
}
return 0;
}
getAncestor(element, index) {
let output = element;
for (let i = 0; i < index; i++) {
output = output.parentElement;
}
return output;
}
getAncestorCount(element) {
let count = 0;
while (element.parentElement) {
element = element.parentElement;
count++;
}
return count;
}
}
exports.RobulaPlus = RobulaPlus;
class XPath {
constructor(value) {
this.value = value;
}
getValue() {
return this.value;
}
startsWith(value) {
return this.value.startsWith(value);
}
substring(value) {
return this.value.substring(value);
}
headHasAnyPredicates() {
return this.value.split('/')[2].includes('[');
}
headHasPositionPredicate() {
const splitXPath = this.value.split('/');
const regExp = new RegExp('[[0-9]]');
return splitXPath[2].includes('position()') || splitXPath[2].includes('last()') || regExp.test(splitXPath[2]);
}
headHasTextPredicate() {
return this.value.split('/')[2].includes('text()');
}
addPredicateToHead(predicate) {
const splitXPath = this.value.split('/');
splitXPath[2] += predicate;
this.value = splitXPath.join('/');
}
getLength() {
const splitXPath = this.value.split('/');
let length = 0;
for (const piece of splitXPath) {
if (piece) {
length++;
}
}
return length;
}
}
exports.XPath = XPath;
class RobulaPlusOptions {
constructor() {
/**
* @attribute - attributePriorizationList: A prioritized list of HTML attributes, which are considered in the given order.
* @attribute - attributeBlackList: Contains HTML attributes, which are classified as too fragile and are ignored by the algorithm.
*/
this.attributePriorizationList = ['aria-label', 'name', 'title', 'alt', 'value', 'data-tooltip', 'id'];
this.attributeBlackList = [
'class',
'href',
'src',
'onclick',
'onload',
'tabindex',
'width',
'height',
'style',
'size',
'maxlength',
];
}
}
exports.RobulaPlusOptions = RobulaPlusOptions;