blue-harvest
Version:
protractor helpers
640 lines (595 loc) • 23.2 kB
JavaScript
/**
* @fileoverview Browser-side scripts for Monkey Helper and Action Helpers.
*
* The browser-side part of Monkey Helper can be developed and debugged without
* protractor. Just paste this whole file in Chrome inspector, and you'll get
* most Monkey Helper functions. (The ones that rely on finding elements on
* page, but without timeout support. Timeouts are done on protractor side.)
*
* Hover on the return value of see() or click() in the inspector to see the
* element highlighted in the page. When it breaks, you can try to debug
* browserSideFind(). For example, if below('Foo').see('Bar') is not returning
* the element you would expect, try this in the inspector and step into the
* code:
*
* debugger; see('FooBar');
*
* One known issue (likely fixable with some overkill approach) is that click()
* does not work in many cases. However, it returns the element that would be
* clicked if we were running in protractor, so you can find it on the page and
* click it yourself.
*
* Another problem is that focus is stolen from the page by chrome inspector,
* making it difficult to interact with jfk-select. To work around this problem,
* do the following:
*
* 1. Open the inspector
* 2. Click the jfk-select dropdown so that it opens
* 3. Press F8 - debugger starts and the page gets frozen
* 4. You can now experiment with see(), click(), etc. in the inspector
*
* You can also use this for quick experimentation with the locators while your
* tests are building, when provisiong is broken and you don't have a project,
* etc., but remember that the see(), click(), etc., functions do not implement
* the whole functionality of Monkey Helper, because they are only intended for
* debugging and development of Monkey Helper itself, not the actual tests.
*/
/**
* Browser-side function that locates an element.
*
* Returns:
* - element, if found,
* - a string, in case of an error,
* - true, if the element was not found and that is expected ("not" is true).
*
* You can test Monkey Helper locators directly in Chrome inspector. Paste from
* here to end of file into the Chrome inspector. See also comment at end of
* file.
*
* @param {!Array<{
* using: string,
* value: string,
* direction: ?string,
* wantZero: ?boolean,
* enabled: ?boolean,
* disabled: ?boolean,
* allowCovered: ?boolean,
* allowUnseen: ?boolean,
* }>} locators Identifies the element.
* @param {Object=} opt_options Options that affect matching.
* @return {!WebElement|string|boolean} The element if exactly one was found, or
* an error message, or true if no elements were found and wantZero option was
* given.
*/
var browserSideFind = function(locators, opt_options) {
var epsilon = 0.1;
var options = opt_options || {};
var regExp;
// Finds elements by xpath and returns them as an array.
var byXPath = function(locator, opt_context) {
var iterator = document.evaluate(
locator, opt_context || document.body, null, XPathResult.ANY_TYPE,
null);
var result = [];
for (var x = iterator.iterateNext(); x; x = iterator.iterateNext()) {
result.push(x);
}
return result;
};
// Find elements by string and returns them as an array.
var byString = function(locator) {
var parts = locator.match(/[^']+|[']/g).map(function(part) {
if (part === '\'') return '"\'"';
return '\'' + part.toLowerCase() + '\'';
});
var escapedText =
parts.length > 1 ? 'concat(' + parts.join(',') + ')' : parts[0];
var inputsFound =
byXPath(
'//input[@type="text" or @type="number" or not(@type)] | ' +
'//textarea')
.filter(function(e) {
return e.value == locator;
});
// Finds the bottom-most element whose in the DOM whose text content
// matches given text. So for example given <div><span>bar</span></div>
// it will return the <span>, not the <div>.
var match = function(expr) {
return 'normalize-space(translate(' + expr + ', ' +
' "ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00A0",' +
' "abcdefghijklmnopqrstuvwxyz "))' +
' = ' + escapedText;
};
return byXPath(
'//*[(' + match('.') + ' or ' + match('text()') + ') and ' +
' not(descendant::*[' + match('.') + '])]')
.concat(inputsFound);
};
// Finds elements by regexp and returns them as an array.
var byRegExp = function(e) {
var results = [];
for (var i = 0; i < e.children.length; ++i) {
results = results.concat(byRegExp(e.children[i]));
}
if (results.length) return results;
if (e.textContent ||
e.value && (e.type == 'text' || e.type == 'textarea')) {
var text = (e.textContent || e.value).replace(/\s+/g, ' ').trim();
if (regExp.test(text)) return [e];
}
return [];
};
/**
* Given an element, return all of the elements covering it.
*
* @param {Element} e
* @returns {Array<Element>} all elements covering the argument element.
*/
let coveringElements = function(e) {
let r = e.getBoundingClientRect();
if (!r.height || !r.width) return [];
let x = (r.left + r.right) / 2;
let y = (r.top + r.bottom) / 2;
let elementSet = new Set();
// get the set of elements at the center and corners of the bounding rect.
elementSet.add(document.elementFromPoint(x, y));
elementSet.add(document.elementFromPoint(r.left + 1, r.top + 1));
elementSet.add(document.elementFromPoint(r.left + 1, r.bottom - 1));
elementSet.add(document.elementFromPoint(r.right - 1, r.top + 1));
elementSet.add(document.elementFromPoint(r.right - 1, r.bottom - 1));
// remove the element itself. Will be present if any points are uncovered.
elementSet.delete(e);
return Array.from(elementSet);
};
/**
* Given an element, return a value representing if it can be seen.
*
* @param {Element} e
* @returns {string} - a value from DISPLAY_STATUS_ENUM
*/
let displayStatus = function(e) {
// Check that parent elements are displayed.
let r = e.getBoundingClientRect();
if (!r.height || !r.width) return DISPLAY_STATUS_ENUM.empty;
let x = (r.left + r.right) / 2;
let y = (r.top + r.bottom) / 2;
if (hitsAncestorButton(e, x, y)) {
return DISPLAY_STATUS_ENUM.visible;
}
// Move up to the first clickable element, because elementFromPoint
// ignores the unclickables.
let style = window.getComputedStyle(e);
while (style.pointerEvents === 'none') {
// If element is in a hidden container, break and return false.
if (isUnseenStyle(style)) {
return DISPLAY_STATUS_ENUM.invisible;
}
// Move one up.
e = e.parentElement;
style = window.getComputedStyle(e);
}
if (isUnseenStyle(style) && options.allowUnseen !== true) {
return DISPLAY_STATUS_ENUM.invisible;
}
if (options.allowCovered) return DISPLAY_STATUS_ENUM.visible;
// Check that the element is not covered (by glass, for example).
return (hitsElement(x, y, e) || hitsElement(r.left + 1, r.top + 1, e) ||
hitsElement(r.left + 1, r.bottom - 1, e) ||
hitsElement(r.right - 1, r.top + 1, e) ||
hitsElement(r.right - 1, r.bottom - 1, e)) ?
DISPLAY_STATUS_ENUM.visible :
DISPLAY_STATUS_ENUM.covered;
};
const DISPLAY_STATUS_ENUM = {
covered: 'COVERED',
empty: 'EMPTY',
invisible: 'INVISIBLE',
visible: 'VISIBLE',
};
// If f is below e, returns the distance down from e to f. Otherwise 1e100.
var distanceDown = function(e, f) {
e = e.getBoundingClientRect();
f = f.getBoundingClientRect();
// Boundary error, so that in instances and templates creation, in directive
// for machine types, CPU value is checked (below('vCPU').see(value) ).
if (e.bottom <= f.top + 2) return f.top - e.bottom + 2;
return 1e100;
};
// If f is rightwards from e returns the distance right from e to f, or 1e100.
var distanceRight = function(e, f) {
e = e.getBoundingClientRect();
f = f.getBoundingClientRect();
// Note the difference between this and distanceDown. This is more relaxed
// than distanceDown, to support cases seen in Pantheon, e.g. a label
// overlapping by one pixel with a warning icon to the right of it.
e = (e.left + e.right) / 2;
f = (f.left + f.right) / 2;
if (e <= f) return f - e;
return 1e100;
};
// Given an array of elements, returns a string with HTML of some initial (up
// to 3) elements from the array, for inclusion in error messages.
var elementsToString = function(a) {
var s = '';
for (var i = 0; i < a.length && i < 3; ++i) {
s += a[i].outerHTML + '\n';
}
if (a.length > 3) s += '...\n';
return s;
};
// Finds exactly one element globally (or zero if locator.wantZero is set).
var findGlobal = function(locator) {
if (locator.direction === 'at') {
return {
rightOf: findGlobal(locator.rightOf),
under: findGlobal(locator.under),
};
}
var all = selectAll(locator);
var candidateElements = all.filter(hasCorrectDisplayStatus);
// Try to auto-scroll to see an element.
if (!candidateElements.length && options.scroll !== false) {
var maybeDisplayed = all.filter(isMaybeDisplayed);
if (maybeDisplayed.length == 1 && shouldAutoScroll(maybeDisplayed[0])) {
maybeDisplayed[0].scrollIntoView();
if (hasCorrectDisplayStatus(maybeDisplayed[0])) {
candidateElements = maybeDisplayed;
}
}
}
// Check result.
if (locator.wantZero) {
if (candidateElements.length) {
throw 'Found unwanted: ' + locatorToString(locator) + '\n' +
elementsToString(candidateElements);
}
return true;
}
if (!candidateElements.length) {
var extraMessage = '';
if (all.length > 0) {
extraMessage =
' Number of hidden elements: ' + all.length + '\nElements:\n';
for (let i = 0; i < all.length; i++) {
const hiddenElement = all[i];
const status = displayStatus(hiddenElement);
extraMessage +=
`${formatHtmlString(hiddenElement.outerHTML)}: ${status}\n`;
if (status === DISPLAY_STATUS_ENUM.covered) {
const elementsCovering = coveringElements(hiddenElement);
extraMessage += `\tby ${elementsCovering.length} elements\n`;
for (let j = 0; j < elementsCovering.length; j++) {
let elementCovering = elementsCovering[j];
if (!elementCovering) {
extraMessage += '\t\tunknown element';
} else {
extraMessage +=
`\t\t${formatHtmlString(elementCovering.outerHTML)}\n`;
}
}
}
}
}
throw 'Looking for ' + locatorToString(locator) +
' failed. No elements are displayed.' + extraMessage;
}
if (candidateElements.length > 1) {
throw 'Looking for ' + locatorToString(locator) +
' failed. More than one element is ' +
'displayed, number of displayed elements: ' + candidateElements.length +
'\nElements:\n' + elementsToString(candidateElements);
}
var disabledAncestor = getDisabledAncestor(candidateElements[0]);
if (locator.enabled && disabledAncestor) {
throw 'Looking for ' + locatorToString(locator) +
' failed. Element found is disabled.' +
'\nThe element:\n' + disabledAncestor.outerHTML;
}
if (locator.disabled && !disabledAncestor) {
throw 'Looking for ' + locatorToString(locator) +
' failed. Element found is enabled.' +
'\nThe element:\n' + candidateElements[0].outerHTML;
}
return candidateElements[0];
};
// Finds an element relative to another.
var findRelative = function(reference, locator, type) {
if (locator.direction === 'at') {
return {
rightOf: findRelative(reference, locator.rightOf, type),
under: findRelative(reference, locator.under, type),
};
}
// Find all and calculate distances.
var all = selectAll(locator).map(function(e) {
var distance = 1e100;
if (type == 'below') {
distance = distanceDown(reference, e);
} else if (type == 'under' && isUnderOrOver(reference, e)) {
distance = distanceDown(reference, e);
} else if (type == 'leftOf' && isLeftOfOrRightOf(reference, e)) {
distance = distanceRight(e, reference);
} else if (type == 'rightOf' && isLeftOfOrRightOf(reference, e)) {
distance = distanceRight(reference, e);
} else if (
type == 'inside' && isUnderOrOver(reference, e) &&
isLeftOfOrRightOf(reference, e)) {
distance = 0;
} else if (
type == 'at' && isUnderOrOver(reference.under, e) &&
isLeftOfOrRightOf(reference.rightOf, e)) {
if (distanceDown(reference.under, e) < 1e100 &&
distanceRight(reference.rightOf, e) < 1e100) {
// Do not allow for any ambiguity.
distance = 0;
}
}
return {element: e, distance: distance};
});
// Remove those in a wrong direction; sort.
all = all.filter(function(e) {
return e.distance != 1e100;
})
.sort(function(a, b) {
return a.distance - b.distance;
});
// Filter displayed.
var candidateElements = all.filter(function(e) {
return hasCorrectDisplayStatus(e.element);
});
// Try to auto-scroll to see an element.
if (!candidateElements.length && options.scroll !== false) {
var maybeDisplayed = all.filter(function(e) {
return isMaybeDisplayed(e.element);
});
if (maybeDisplayed.length) {
maybeDisplayed[0].element.scrollIntoView();
candidateElements = all.filter(function(e) {
return hasCorrectDisplayStatus(e.element);
});
}
}
// Get the closest one(s).
var minDist = candidateElements.length && candidateElements[0].distance;
var found = candidateElements.filter(function(e) {
return Math.abs(e.distance - minDist) < 1;
});
// Check result.
if (locator.wantZero) {
if (found.length) {
throw 'Found unwanted: ' + locatorToString(locator) + '\n' +
elementsToString(found);
}
return true;
}
const referenceString = reference.rightOf && reference.under ?
'rightOf: ' + reference.rightOf.outerHTML +
' and under: ' + reference.under.outerHTML :
reference.outerHTML;
if (!found.length) {
var extraMessage = '';
if (candidateElements.length > 0) {
extraMessage = '\nDisqualified elements: ' +
elementsToString(candidateElements.map(function(e) {
return e.element;
}));
}
throw 'No elements are within area defined by reference element. ' +
'\nReference element: ' + referenceString + extraMessage;
}
if (found.length > 1) {
throw 'More than one element seems to be nearest to reference element. ' +
'Cannot choose.\nReference element: ' + referenceString +
'\nElements: ' + elementsToString(found.map(function(e) {
return e.element;
}));
}
var disabledAncestor = getDisabledAncestor(found[0].element);
if (locator.enabled && disabledAncestor) {
throw 'Element found within area is disabled.' +
'\nReference element: ' + referenceString + '\nThe element:\n' +
disabledAncestor.outerHTML;
}
if (locator.disabled && !disabledAncestor) {
throw 'Element found within area is enabled.' +
'\nReference element: ' + referenceString + '\nThe element:\n' +
found[0].element.outerHTML;
}
return found[0].element;
};
/**
* Take a string for HTML and strip out newlines and empty space in tags
*
* @param {string} s - a string of HTML content
* @returns {string} the input without any new-lines or empty space between tags
*/
let formatHtmlString = function(s) {
return s.replace('\n', '').replace(/>\s*</, '><');
};
// Workaround for buttons in Firefox to detect properly.
var hitsAncestorButton = function(e, x, y) {
var elementAtPoint = document.elementFromPoint(x, y);
if (elementAtPoint && elementAtPoint.tagName === 'BUTTON') {
for (var p = e.parentElement; p; p = p.parentElement) {
if (p === elementAtPoint) {
return true;
}
}
}
return false;
};
// Checks that clicking at x, y hits element e (or its descendant).
var hitsElement = function(x, y, e) {
for (var f = document.elementFromPoint(x, y); f; f = f.parentElement) {
if (e === f) return true;
}
return false;
};
/**
* Checks that an element is visble and not obscured by any clickable element.
* Accounts for options passed to browserSideFind.
*
* @param {Element} e
* @returns {boolean}
*/
var hasCorrectDisplayStatus = function(e) {
return displayStatus(e) === DISPLAY_STATUS_ENUM.visible;
};
var isUnseenStyle = function(style) {
return style.visibility == 'hidden' || style.display == 'none' ||
style.opacity == 0;
};
// Returns true if the projection of the two elements on the top edge of the
// screen is a single interval, not two disjoint intervals. They must overlap
// for more than epsilon.
var isUnderOrOver = function(e, f) {
e = e.getBoundingClientRect();
f = f.getBoundingClientRect();
return (e.right - f.left > epsilon) && (f.right - e.left > epsilon);
};
// Returns true if the projection of the two elements on the left edge of the
// screen is a single interval, not two disjoint intervals. They must overlap
// for more than epsilon.
var isLeftOfOrRightOf = function(e, f) {
e = e.getBoundingClientRect();
f = f.getBoundingClientRect();
return (e.bottom - f.top > epsilon) && (f.bottom - e.top > epsilon);
};
// Checks that an element can be potentially displayed after scoll.
var isMaybeDisplayed = function(e) {
return e.offsetWidth && e.offsetHeight &&
e.getBoundingClientRect().right > 0;
};
// Checks if an element or its ancestors are disabled. Returns disabled
// element or innermost disabled ancestor.
var getDisabledAncestor = function(e) {
var xpath =
'ancestor-or-self::*[@disabled or contains(@class, "p6n-disabled")]';
return byXPath(xpath, e)[0];
};
// Converts a locator (e.g. by.css) to a string, for error messages.
var locatorToString = function(locator) {
if (locator.using == 'string' || locator.using == 'regexp')
return locator.value;
return locator.using + '(' + locator.value + ')';
};
// Finds elements by a locator (xpath, css or text) and returns them as an
// array.
var selectAll = function(locator) {
// See
// https://github.com/SeleniumHQ/selenium/blob/master/javascript/webdriver/locators.js#L110
switch (locator.using) {
case 'string':
return byString(locator.value);
case 'regexp':
regExp = eval(locator.value);
return byRegExp(document.body);
case 'css selector':
return Array.from(document.body.querySelectorAll(locator.value));
case 'xpath':
return byXPath(locator.value);
default:
throw 'Invalid locator ' + locatorToString(locator);
}
};
/**
* Returns true iff we allow to auto-scroll given element.
*
* @param {HTMLElement} element
* @return {boolean}
*/
var shouldAutoScroll = function(element) {
// Special case for ng2 section nav: we don't want to horizontally scroll
// a hidden section name: https://screenshot.googleplex.com/YCiZCRRv93D
// More information: section_nav_ng2 mod.
return !element.classList.contains('cfc-page-displayName');
};
// Scrolls to given locator.
var scrollTo = function(locator) {
var maybeDisplayed = selectAll(locator).filter(isMaybeDisplayed);
if (maybeDisplayed.length != 1) {
throw 'Scrolling to ' + locatorToString(locator) + ' failed. ' +
'Expected exactly one displayable element to match. Matched: ' +
maybeDisplayed.length + '\nElements:\n' +
elementsToString(maybeDisplayed);
}
var e = maybeDisplayed[0];
e.scrollIntoView();
if (!hasCorrectDisplayStatus(e)) {
throw 'Scrolling to ' + locatorToString(locator) + ' failed. The ' +
'displayable element did not become displayed after ' +
'scrollIntoView(). The element:\n' + e.outerHTML;
}
};
try {
if (options.scroll) {
if (locators.length != 1)
throw 'Assert failed: scroll requires exactly one locator.';
scrollTo(locators[0]);
return true;
}
// wantZero, enabled, and disabled options apply only to the last selector.
locators[locators.length - 1].wantZero = options.wantZero;
locators[locators.length - 1].enabled = options.enabled;
locators[locators.length - 1].disabled = options.disabled;
var found = null;
locators.forEach(function(locator, i) {
if (!i) {
found = findGlobal(locator);
} else {
found = findRelative(found, locator, locators[i - 1].direction);
}
});
return found;
} catch (e) {
if (typeof e == 'string') return e;
throw e;
}
};
/**
* @param {string|number|!RegExp|{using: string, value: string}} locator
* @return {{using: string, value: string}}
*/
var browserSideLocator = function(locator) {
if (typeof locator == 'string' || typeof locator == 'number')
return {using: 'string', value: String(locator)};
if (typeof locator == 'object' && locator.constructor.name == 'RegExp')
return {using: 'regexp', value: locator.toString()};
if (locator && (locator.using == 'css selector' || locator.using == 'xpath'))
return {using: locator.using, value: locator.value};
throw new Error(
'Only text, number, RegExp, by.css() and by.xpath() are supported by ' +
'helpers, sorry: ' + locator);
};
if (typeof exports != 'undefined') {
/** We're in a node.js environment, export for other modules to use. */
exports.browserSideFind = browserSideFind;
exports.browserSideLocator = browserSideLocator;
}
// Below are functions used for debugging purposes.
var by = {
css: function(value) {
return {using: 'css selector', value: value};
},
xpath: function(value) {
return {using: 'xpath', value: value};
}
};
var click = function(locator) {
var e = see(locator, {allowUnseen: true});
if (e.click) e.click();
return e;
};
var see = function(locator, opt_options) {
return browserSideFind([browserSideLocator(locator)], opt_options);
};
var not = {
see: function(locator) {
return see(locator, {wantZero: true});
}
};
var scroll = function(locator) {
return see(locator, {scroll: true});
};
var DROPDOWN = by.css('jfk-select,.p6n-dropdown-menu');
var INPUT = by.css('input');