MOBILE-3320 behat: Improve element locators

main
Noel De Martin 2021-06-03 11:20:36 +02:00
parent 98e5a63eb6
commit 3ce506ea01
2 changed files with 156 additions and 118 deletions

View File

@ -11,13 +11,13 @@
* *
* @param {string} text Information to log * @param {string} text Information to log
*/ */
var log = function(text) { var log = function() {
var now = new Date(); var now = new Date();
var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' + var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + '.' + String(now.getSeconds()).padStart(2, '0') + '.' +
String(now.getMilliseconds()).padStart(2, '0'); String(now.getMilliseconds()).padStart(2, '0');
console.log('BEHAT: ' + nowFormatted + ' ' + text); // eslint-disable-line no-console console.log('BEHAT: ' + nowFormatted, ...arguments); // eslint-disable-line no-console
}; };
/** /**
@ -185,13 +185,14 @@
if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none')
return false; return false;
if (element.parentElement === container) const parentElement = getParentElement(element);
if (parentElement === container)
return true; return true;
if (!element.parentElement) if (!parentElement)
return false; return false;
return isElementVisible(element.parentElement, container); return isElementVisible(parentElement, container);
}; };
/** /**
@ -210,10 +211,11 @@
) )
return true; return true;
if (!element.parentElement || element.parentElement === container) const parentElement = getParentElement(element);
if (!parentElement || parentElement === container)
return false; return false;
return isElementSelected(element.parentElement, container); return isElementSelected(parentElement, container);
}; };
/** /**
@ -352,19 +354,28 @@
return [...uniqueElements]; return [...uniqueElements];
}; };
/**
* Get parent element, including Shadow DOM parents.
*
* @param {HTMLElement} element Element.
* @return {HTMLElement} Parent element.
*/
var getParentElement = function(element) {
return element.parentElement ?? element.getRootNode()?.host ?? null;
};
/** /**
* Function to find elements based on their text or Aria label. * Function to find elements based on their text or Aria label.
* *
* @param {string} text Text (full or partial) * @param {object} locator Element locator.
* @param {string} [near] Optional 'near' text - if specified, must have a single match on page
* @return {HTMLElement} Found elements * @return {HTMLElement} Found elements
*/ */
var findElementsBasedOnText = function(text, near) { var findElementsBasedOnText = function(locator) {
const topContainer = document.querySelector('ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html'); const topContainer = document.querySelector('ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html');
let container = topContainer; let container = topContainer;
if (topContainer && near) { if (topContainer && locator.near) {
const nearElements = findElementsBasedOnText(near); const nearElements = findElementsBasedOnText(locator.near);
if (nearElements.length === 0) { if (nearElements.length === 0) {
throw new Error('There was no match for near text') throw new Error('There was no match for near text')
@ -375,19 +386,22 @@
throw new Error('Too many matches for near text'); throw new Error('Too many matches for near text');
} }
container = nearElementsAncestors[0].parentElement; container = getParentElement(nearElementsAncestors[0]);
} else { } else {
container = nearElements[0].parentElement; container = getParentElement(nearElements[0]);
} }
} }
do { do {
const elements = findElementsBasedOnTextWithin(container, text); const elements = findElementsBasedOnTextWithin(container, locator.text);
const filteredElements = locator.selector
? elements.filter(element => element.matches(locator.selector))
: elements;
if (elements.length > 0) { if (filteredElements.length > 0) {
return elements; return filteredElements;
} }
} while ((container = container.parentElement) && container !== topContainer); } while ((container = getParentElement(container)) && container !== topContainer);
return []; return [];
}; };
@ -444,10 +458,13 @@
} else { } else {
switch (button) { switch (button) {
case 'back': case 'back':
foundButton = findElementsBasedOnText('Back')[0]; foundButton = findElementsBasedOnText({ text: 'Back' })[0];
break; break;
case 'main menu': case 'main menu':
foundButton = findElementsBasedOnText('more', 'Notifications')[0]; foundButton = findElementsBasedOnText({
text: 'more',
near: { text: 'Notifications' },
})[0];
break; break;
default: default:
return 'ERROR: Unsupported standard button type'; return 'ERROR: Unsupported standard button type';
@ -500,15 +517,14 @@
/** /**
* Function to find an arbitrary item based on its text or aria label. * Function to find an arbitrary item based on its text or aria label.
* *
* @param {string} text Text (full or partial) * @param {object} locator Element locator.
* @param {string} [near] Optional 'near' text
* @return {string} OK if successful, or ERROR: followed by message * @return {string} OK if successful, or ERROR: followed by message
*/ */
var behatFind = function(text, near) { var behatFind = function(locator) {
log(`Action - Find ${text}`); log('Action - Find', locator);
try { try {
const element = findElementsBasedOnText(text, near)[0]; const element = findElementsBasedOnText(locator)[0];
if (!element) { if (!element) {
return 'ERROR: No matches for text'; return 'ERROR: No matches for text';
@ -540,15 +556,14 @@
/** /**
* Check whether an item is selected or not. * Check whether an item is selected or not.
* *
* @param {string} text Text (full or partial) * @param {object} locator Element locator.
* @param {string} near Optional 'near' text
* @return {string} YES or NO if successful, or ERROR: followed by message * @return {string} YES or NO if successful, or ERROR: followed by message
*/ */
var behatIsSelected = function(text, near) { var behatIsSelected = function(locator) {
log(`Action - Is Selected: "${text}"${near ? ` near "${near}"`: ''}`); log('Action - Is Selected', locator);
try { try {
const element = findElementsBasedOnText(text, near)[0]; const element = findElementsBasedOnText(locator)[0];
return isElementSelected(element, document.body) ? 'YES' : 'NO'; return isElementSelected(element, document.body) ? 'YES' : 'NO';
} catch (error) { } catch (error) {
@ -559,16 +574,15 @@
/** /**
* Function to press arbitrary item based on its text or Aria label. * Function to press arbitrary item based on its text or Aria label.
* *
* @param {string} text Text (full or partial) * @param {object} locator Element locator.
* @param {string} near Optional 'near' text
* @return {string} OK if successful, or ERROR: followed by message * @return {string} OK if successful, or ERROR: followed by message
*/ */
var behatPress = function(text, near) { var behatPress = function(locator) {
log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near)); log('Action - Press', locator);
var found; var found;
try { try {
found = findElementsBasedOnText(text, near)[0]; found = findElementsBasedOnText(locator)[0];
if (!found) { if (!found) {
return 'ERROR: No matches for text'; return 'ERROR: No matches for text';
@ -697,8 +711,7 @@
return 'ERROR: No matches for text'; return 'ERROR: No matches for text';
} }
} else { } else {
const elements = findElementsBasedOnText(field); found = findElementsBasedOnText({ text: field, selector: 'input, textarea' })[0];
var found = elements.filter(element => element.matches('input, textarea'))[0];
if (!found) { if (!found) {
return 'ERROR: No matches for text'; return 'ERROR: No matches for text';

View File

@ -103,18 +103,15 @@ class behat_app extends behat_base {
/** /**
* Finds elements in the app. * Finds elements in the app.
* *
* @Then /^I should(?P<not_boolean> not)? find "(?P<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? in the app$/ * @Then /^I should( not)? find (".+") in the app$/
* @param string $not * @param bool $not
* @param string $text * @param object $locator
* @param string $near
*/ */
public function i_find_in_the_app($not, $text='', $near='') { public function i_find_in_the_app(bool $not, object $locator) {
$not = !empty($not); $locatorjson = json_encode($locator);
$text = addslashes_js($text);
$near = addslashes_js($near);
$this->spin(function() use ($not, $text, $near) { $this->spin(function() use ($not, $locatorjson) {
$result = $this->evaluate_script("return window.behat.find(\"$text\", \"$near\");"); $result = $this->evaluate_script("return window.behat.find($locatorjson);");
if ($not && $result === 'OK') { if ($not && $result === 'OK') {
throw new DriverException('Error, found an item that should not be found'); throw new DriverException('Error, found an item that should not be found');
@ -126,22 +123,22 @@ class behat_app extends behat_base {
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
/** /**
* Check if elements are selected in the app. * Check if elements are selected in the app.
* *
* @Then /^"(?P<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? should(?P<not_boolean> not)? be selected in the app$/ * @Then /^(".+") should( not)? be selected in the app$/
* @param string $text * @param object $locator
* @param bool $not
*/ */
public function be_selected_in_the_app($text, $near='', $not='') { public function be_selected_in_the_app(object $locator, bool $not = false) {
$not = !empty($not); $locatorjson = json_encode($locator);
$text = addslashes_js($text);
$near = addslashes_js($near);
$this->spin(function() use ($not, $text, $near) { $this->spin(function() use ($locatorjson, $not) {
$result = $this->evaluate_script("return window.behat.isSelected(\"$text\", \"$near\");"); $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
@ -160,6 +157,7 @@ class behat_app extends behat_base {
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -403,7 +401,7 @@ class behat_app extends behat_base {
: $page->find('xpath', '//core-login-site-onboarding'); : $page->find('xpath', '//core-login-site-onboarding');
if ($element) { if ($element) {
$this->i_press_in_the_app('Skip'); $this->i_press_in_the_app($this->parse_element_locator('"Skip"'));
} }
// Login screen found. // Login screen found.
@ -431,7 +429,7 @@ class behat_app extends behat_base {
global $CFG; global $CFG;
$this->i_set_the_field_in_the_app($this->islegacy ? 'campus.example.edu' : 'Your site', $CFG->wwwroot); $this->i_set_the_field_in_the_app($this->islegacy ? 'campus.example.edu' : 'Your site', $CFG->wwwroot);
$this->i_press_in_the_app($this->islegacy ? 'Connect!' : 'Connect to your site'); $this->i_press_in_the_app($this->parse_element_locator($this->islegacy ? '"Connect!"' : '"Connect to your site"'));
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -448,7 +446,7 @@ class behat_app extends behat_base {
// Note there are two 'Log in' texts visible (the title and the button) so we have to use // Note there are two 'Log in' texts visible (the title and the button) so we have to use
// a 'near' value here. // a 'near' value here.
$this->i_press_in_the_app('Log in', 'Forgotten'); $this->i_press_in_the_app($this->parse_element_locator('"Log in" near "Forgotten"'));
// Wait until the main page appears. // Wait until the main page appears.
$this->spin( $this->spin(
@ -467,18 +465,21 @@ class behat_app extends behat_base {
/** /**
* Presses standard buttons in the app. * Presses standard buttons in the app.
* *
* @Given /^I press the (?P<button_name>back|main menu|page menu) button in the app$/ * @Given /^I press the (back|main menu|page menu) button in the app$/
* @param string $button Button type * @param string $button Button type
* @throws DriverException If the button push doesn't work * @throws DriverException If the button push doesn't work
*/ */
public function i_press_the_standard_button_in_the_app(string $button) { public function i_press_the_standard_button_in_the_app(string $button) {
$this->spin(function($context, $args) use ($button) { $this->spin(function() use ($button) {
$result = $this->evaluate_script("return window.behat.pressStandard('{$button}');"); $result = $this->evaluate_script("return window.behat.pressStandard('$button');");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing standard button - ' . $result); throw new DriverException('Error pressing standard button - ' . $result);
} }
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -519,13 +520,16 @@ class behat_app extends behat_base {
* @throws DriverException If there isn't a popup to close * @throws DriverException If there isn't a popup to close
*/ */
public function i_close_the_popup_in_the_app() { public function i_close_the_popup_in_the_app() {
$this->spin(function($context, $args) { $this->spin(function() {
$result = $this->evaluate_script("return window.behat.closePopup();"); $result = $this->evaluate_script("return window.behat.closePopup();");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error closing popup - ' . $result); throw new DriverException('Error closing popup - ' . $result);
} }
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -535,13 +539,24 @@ class behat_app extends behat_base {
* Note it is difficult to use the standard 'click on' or 'press' steps because those do not * Note it is difficult to use the standard 'click on' or 'press' steps because those do not
* distinguish visible items and the app always has many non-visible items in the DOM. * distinguish visible items and the app always has many non-visible items in the DOM.
* *
* @Then /^I press "(?P<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? in the app$/ * @Then /^I press (".+") in the app$/
* @param string $text Text identifying click target * @param object $locator Element locator
* @param string $near Text identifying a nearby unique piece of text
* @throws DriverException If the press doesn't work * @throws DriverException If the press doesn't work
*/ */
public function i_press_in_the_app($text, $near='') { public function i_press_in_the_app(object $locator) {
$this->press($text, $near); $locatorjson = json_encode($locator);
$this->spin(function() use ($locatorjson) {
$result = $this->evaluate_script("return window.behat.press($locatorjson);");
if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
} }
/** /**
@ -551,34 +566,32 @@ class behat_app extends behat_base {
* with JavaScript, and clicks may not work until they are initialized properly which may cause flaky tests due * with JavaScript, and clicks may not work until they are initialized properly which may cause flaky tests due
* to race conditions. * to race conditions.
* *
* @Then /^I (?P<select_string>unselect|select) "(?P<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? in the app$/ * @Then /^I (unselect|select) (".+") in the app$/
* @param string $selectedtext Select/unselect string * @param string $selectedtext
* @param string $text Text identifying click target * @param object $locator
* @param string $near Text identifying a nearby unique piece of text
* @throws DriverException If the press doesn't work * @throws DriverException If the press doesn't work
*/ */
public function i_select_in_the_app(string $selectedtext, string $text, string $near = '') { public function i_select_in_the_app(string $selectedtext, object $locator) {
$selected = $selectedtext === 'select' ? 'YES' : 'NO'; $selected = $selectedtext === 'select' ? 'YES' : 'NO';
$text = addslashes_js($text); $locatorjson = json_encode($locator);
$near = addslashes_js($near);
$this->spin(function() use ($selectedtext, $selected, $text, $near) { $this->spin(function() use ($selectedtext, $selected, $locatorjson) {
// Don't do anything if the item is already in the expected state. // Don't do anything if the item is already in the expected state.
$result = $this->evaluate_script("return window.behat.isSelected(\"$text\", \"$near\");"); $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);");
if ($result === $selected) { if ($result === $selected) {
return true; return true;
} }
// Press item. // Press item.
$result = $this->evaluate_script("return window.behat.press(\"$text\", \"$near\");"); $result = $this->evaluate_script("return window.behat.press($locatorjson);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing item - ' . $result);
} }
// Check that it worked as expected. // Check that it worked as expected.
$result = $this->evaluate_script("return window.behat.isSelected(\"$text\", \"$near\");"); $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
@ -606,54 +619,31 @@ class behat_app extends behat_base {
return !is_null($logininput); return !is_null($logininput);
} }
/**
* Clicks on / touches something that is visible in the app, near some other text.
*
* If the $near is specified then when there are multiple matches, it picks the one
* nearest (in DOM terms) $near. $near should be an exact match, or a partial match that only
* has one result.
*
* @param behat_base $base Behat context
* @param string $text Text identifying click target
* @param string $near Text identifying a nearby unique piece of text
* @throws DriverException If the press doesn't work
*/
protected function press(string $text, string $near = '') {
$text = addslashes_js($text);
$near = addslashes_js($near);
$this->spin(function() use ($text, $near) {
$result = $this->evaluate_script("return window.behat.press(\"$text\", \"$near\");");
if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
}
/** /**
* Sets a field to the given text value in the app. * Sets a field to the given text value in the app.
* *
* Currently this only works for input fields which must be identified using a partial or * Currently this only works for input fields which must be identified using a partial or
* exact match on the placeholder text. * exact match on the placeholder text.
* *
* @Given /^I set the field "(?P<field_name>(?:[^"]|\\")*)" to "(?P<text_string>(?:[^"]|\\")*)" in the app$/ * @Given /^I set the field "((?:[^"]|\\")+)" to "((?:[^"]|\\")+)" in the app$/
* @param string $field Text identifying field * @param string $field Text identifying field
* @param string $value Value for field * @param string $value Value for field
* @throws DriverException If the field set doesn't work * @throws DriverException If the field set doesn't work
*/ */
public function i_set_the_field_in_the_app(string $field, string $value) { public function i_set_the_field_in_the_app(string $field, string $value) {
$this->spin(function($context, $args) use ($field, $value) { $field = addslashes_js($field);
$result = $this->evaluate_script('return window.behat.setField("' . $value = addslashes_js($value);
addslashes_js($field) . '", "' . addslashes_js($value) . '");');
$this->spin(function() use ($field, $value) {
$result = $this->evaluate_script("return window.behat.setField(\"$field\", \"$value\");");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error setting field - ' . $result); throw new DriverException('Error setting field - ' . $result);
} }
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -662,7 +652,7 @@ class behat_app extends behat_base {
* *
* This can be used to see if the app went to the expected page. * This can be used to see if the app went to the expected page.
* *
* @Then /^the header should be "(?P<text_string>(?:[^"]|\\")*)" in the app$/ * @Then /^the header should be "((?:[^"]|\\")+)" in the app$/
* @param string $text Expected header text * @param string $text Expected header text
* @throws DriverException If the header can't be retrieved * @throws DriverException If the header can't be retrieved
* @throws ExpectationException If the header text is different to the expected value * @throws ExpectationException If the header text is different to the expected value
@ -690,12 +680,10 @@ class behat_app extends behat_base {
/** /**
* Check that the app opened a new browser tab. * Check that the app opened a new browser tab.
* *
* @Given /^the app should(?P<not_boolean> not)? have opened a browser tab$/ * @Given /^the app should( not)? have opened a browser tab$/
* @param string $not * @param bool $not
*/ */
public function the_app_should_have_opened_a_browser_tab($not = '') { public function the_app_should_have_opened_a_browser_tab(bool $not) {
$not = !empty($not);
$this->spin(function() use ($not) { $this->spin(function() use ($not) {
$openedbrowsertab = count($this->getSession()->getWindowNames()) === 2; $openedbrowsertab = count($this->getSession()->getWindowNames()) === 2;
@ -748,11 +736,48 @@ class behat_app extends behat_base {
/** /**
* Switch navigator online mode. * Switch navigator online mode.
* *
* @Given /^I switch offline mode to "(?P<offline_string>(?:[^"]|\\")*)"$/ * @Given /^I switch offline mode to "(true|false)"$/
* @param string $offline New value for navigator online mode * @param string $offline New value for navigator online mode
* @throws DriverException If the navigator.online mode is not available * @throws DriverException If the navigator.online mode is not available
*/ */
public function i_switch_offline_mode(string $offline) { public function i_switch_offline_mode(string $offline) {
$this->execute_script('appProvider.setForceOffline(' . $offline . ');'); $this->execute_script("appProvider.setForceOffline($offline);");
} }
/**
* Parse an element locator string.
*
* @Transform /^".+"$/
* @param string $text Element locator string.
* @return object
*/
public function parse_element_locator($text): object {
preg_match('/^"((?:[^"]|\\")+?)"(?: "([^"]+?)")?(?: near "((?:[^"]|\\")+?)"(?: "([^"]+?)")?)?$/', $text, $matches);
$locator = [
'text' => str_replace('\\"', '"', $matches[1]),
'selector' => $matches[2] ?? null,
];
if (!empty($matches[3])) {
$locator['near'] = (object) [
'text' => str_replace('\\"', '"', $matches[3]),
'selector' => $matches[4] ?? null,
];
}
return (object) $locator;
}
/**
* Parse a negation string.
*
* @Transform /^not $/
* @param string $not Negation string.
* @return bool
*/
public function parse_negation(string $not): bool {
return !empty($not);
}
} }