Commit c1faf86b authored by David Monllaó's avatar David Monllaó
Browse files

MDL-42625 behat: Make behat pacient

* When looking for texts inside the page or inside
  other containers we should wait until the elements
  are visible.
* Same when expanding tree nodes.
* Normalizing loops to spin() function using
  behat_base::TIMEOUT and behat_base::EXTENDED_TIMEOUT,
  leaving TIMEOUT for DOM load processes and
  EXTENDED_TIMEOUT for long processes that involves JS
  too.
* Add page load waits between actions that involves
  reloading the page.
parent 519449c5
......@@ -56,27 +56,32 @@ class behat_backup extends behat_base {
// Go to homepage.
$this->getSession()->visit($this->locate_path('/'));
$this->wait();
// Click the course link.
$this->find_link($backupcourse)->click();
$this->wait();
// Click the backup link.
$this->find_link(get_string('backup'))->click();
$this->wait();
// Initial settings.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('backupstage1action', 'backup'))->press();
$this->wait();
// Schema settings.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('backupstage2action', 'backup'))->press();
$this->wait();
// Confirmation and review, backup filename can also be specified.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('backupstage4action', 'backup'))->press();
// Waiting for it to finish.
$this->wait();
$this->wait(self::EXTENDED_TIMEOUT);
// Last backup continue button.
$this->find_button(get_string('backupstage16action', 'backup'))->press();
......@@ -101,12 +106,15 @@ class behat_backup extends behat_base {
// Go to homepage.
$this->getSession()->visit($this->locate_path('/'));
$this->wait();
// Click the course link.
$this->find_link($tocourse)->click();
$this->wait();
// Click the import link.
$this->find_link(get_string('import'))->click();
$this->wait();
// Select the course.
$exception = new ExpectationException('"' . $fromcourse . '" course not found in the list of courses to import from', $this->getSession());
......@@ -121,18 +129,21 @@ class behat_backup extends behat_base {
$radionode->click();
$this->find_button(get_string('continue'))->press();
$this->wait();
// Initial settings.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('importbackupstage1action', 'backup'))->press();
$this->wait();
// Schema settings.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('importbackupstage2action', 'backup'))->press();
$this->wait();
// Run it.
$this->find_button(get_string('importbackupstage4action', 'backup'))->press();
$this->wait();
$this->wait(self::EXTENDED_TIMEOUT);
// Continue and redirect to 'to' course.
$this->find_button(get_string('continue'))->press();
......@@ -294,10 +305,12 @@ class behat_backup extends behat_base {
// Settings.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('restorestage4action', 'backup'))->press();
$this->wait();
// Schema.
$this->fill_backup_restore_form($options);
$this->find_button(get_string('restorestage8action', 'backup'))->press();
$this->wait();
// Review, no options here.
$this->find_button(get_string('restorestage16action', 'backup'))->press();
......@@ -305,6 +318,9 @@ class behat_backup extends behat_base {
// Last restore continue button, redirected to restore course after this.
$this->find_button(get_string('restorestage32action', 'backup'))->press();
// Long wait when waiting for the restore to finish.
$this->wait(self::EXTENDED_TIMEOUT);
}
/**
......@@ -325,11 +341,15 @@ class behat_backup extends behat_base {
return;
}
// Wait for the page to be loaded and the JS ready.
$this->wait();
// If we find any of the provided options in the current form we should set the value.
$datahash = $options->getRowsHash();
foreach ($datahash as $locator => $value) {
try {
// Using $this->find* to enforce stability over speed.
$fieldnode = $this->find_field($locator);
$field = behat_field_manager::get_form_field($fieldnode, $this->getSession());
$field->set_value($value);
......@@ -341,17 +361,22 @@ class behat_backup extends behat_base {
}
/**
* Waits until the DOM is ready.
* Waits until the DOM and the page Javascript code is ready.
*
* @param int $timeout The number of seconds that we wait.
* @return void
*/
protected function wait() {
protected function wait($timeout = false) {
if (!$this->running_javascript()) {
return;
}
$this->getSession()->wait(self::TIMEOUT * 1000, self::PAGE_READY_JS);
if (!$timeout) {
$timeout = self::TIMEOUT;
}
$this->getSession()->wait($timeout * 1000, self::PAGE_READY_JS);
}
}
......@@ -99,9 +99,11 @@ class behat_course extends behat_base {
}
$table->setRows($rows);
$steps[] = new Given('I fill the moodle form with:', $table);
// Adding a forced wait until editors are loaded as otherwise selenium sometimes tries clicks on the
// format field when the editor is being rendered and the click misses the field coordinates.
$steps[] = new Given('I wait until the editors are loaded');
$steps[] = new Given('I select "' . $formatvalue . '" from "' . $formatfield . '"');
$steps[] = new Given('I wait until the page is ready');
$steps[] = new Given('I fill the moodle form with:', $table);
} else {
$steps[] = new Given('I fill the moodle form with:', $table);
}
......
......@@ -62,11 +62,6 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
*/
const EXTENDED_TIMEOUT = 10;
/**
* Number of retries to wait for the editor to be ready.
*/
const WAIT_FOR_EDITOR_RETRIES = 10;
/**
* The JS code to check that the page is ready.
*/
......@@ -449,16 +444,17 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
list($selector, $locator) = $this->transform_selector($selectortype, $element);
// Exception if it timesout and the element is still there.
$msg = 'The ' . self::EXTENDED_TIMEOUT . ' seconds timeout expired and the element "' . $element . '" is not there';
$msg = 'The "' . $element . '" element does not exist and should exist';
$exception = new ExpectationException($msg, $this->getSession());
// Will stop spinning the find() return false.
// It will stop spinning once the find() method returns true.
$this->spin(
function($context, $args) {
// We don't use behat_base::find as it is already spinning.
if ($context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
return true;
}
return false;
},
array('selector' => $selector, 'locator' => $locator),
self::EXTENDED_TIMEOUT,
......@@ -482,16 +478,17 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
list($selector, $locator) = $this->transform_selector($selectortype, $element);
// Exception if it timesout and the element is still there.
$msg = 'The ' . self::EXTENDED_TIMEOUT . ' seconds timeout expired and the "' . $element . '" element is still there';
$msg = 'The "' . $element . '" element exists and should not exist';
$exception = new ExpectationException($msg, $this->getSession());
// Will stop spinning the find() return false.
// It will stop spinning once the find() method returns false.
$this->spin(
function($context, $args) {
// We don't use behat_base::find as it is already spinning.
// We don't use behat_base::find() as we are already spinning.
if (!$context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
return true;
}
return false;
},
array('selector' => $selector, 'locator' => $locator),
self::EXTENDED_TIMEOUT,
......@@ -514,15 +511,16 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
}
// Exception if it timesout and the element is still there.
$msg = 'The ' . self::EXTENDED_TIMEOUT . ' seconds timeout expired and the "' . $node->getXPath() . '" element is not visible';
$msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
$exception = new ExpectationException($msg, $this->getSession());
// Will stop spinning the find() return false.
// It will stop spinning once the isVisible() method returns true.
$this->spin(
function($context, $args) {
if ($args->isVisible()) {
return true;
}
return false;
},
$node,
self::EXTENDED_TIMEOUT,
......@@ -560,6 +558,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
* so use with caution and only where there will be editors.
*
* @throws ElementNotFoundException
* @throws ExpectationException
* @return void
*/
protected function ensure_editors_are_loaded() {
......@@ -568,59 +567,57 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
return;
}
$lastexception = new Exception('The editors are not properly loaded');
// If there are no editors we don't need to wait.
//$this->getSession()->getPage()->find('css', '.mceEditor');
try {
$this->find('css', '.mceEditor');
} catch (ElementNotFoundException $e) {
return;
}
// We loop until we can interact with all the page editors.
for ($i = 0; $i < self::WAIT_FOR_EDITOR_RETRIES; $i++) {
// Exception if it timesout and the element is not appearing.
$msg = 'The editors are not completely loaded';
$exception = new ExpectationException($msg, $this->getSession());
// Here we know that there are .mceEditor editors in the page and we will
// probably need to interact with them, if we use tinyMCE JS var before
// it exists it will throw an exception and we want to catch it until all
// the page's editors are ready to interact with them.
try {
// Here we know that there are .mceEditor editors in the page and we will
// probably need to interact with them, if we use tinyMCE JS var before
// it exists it will throw an exception and we want to catch it until all
// the page's editors are ready to interact with them.
$this->spin(
function($context) {
// It may return 0 if tinyMCE is loaded but not the instances, so we just loop again.
if ($this->getSession()->evaluateScript('return tinyMCE.editors.length;') > 0) {
// It may be there but not ready.
$iframeready = $this->getSession()->evaluateScript('
var readyeditors = new Array;
for (editorid in tinyMCE.editors) {
if (tinyMCE.editors[editorid].getDoc().readyState === "complete") {
readyeditors[editorid] = editorid;
}
}
if (tinyMCE.editors.length === readyeditors.length) {
return "complete";
}
return "";
');
// Now we know that the editors are there.
if ($iframeready) {
return;
}
$neditors = $context->getSession()->evaluateScript('return tinyMCE.editors.length;');
if ($neditors == 0) {
return false;
}
} catch (Exception $e) {
// Catching any kind of exception and ignoring it until times out.
$lastexception = $e;
// Waiting 0.1 seconds.
usleep(100000);
}
}
// It may be there but not ready.
$iframeready = $context->getSession()->evaluateScript('
var readyeditors = new Array;
for (editorid in tinyMCE.editors) {
if (tinyMCE.editors[editorid].getDoc().readyState === "complete") {
readyeditors[editorid] = editorid;
}
}
if (tinyMCE.editors.length === readyeditors.length) {
return "complete";
}
return "";
');
// If it is not available we throw the last exception.
throw $lastexception;
// Now we know that the editors are there.
if ($iframeready) {
return true;
}
// Loop again if it is not ready.
return false;
},
false,
self::EXTENDED_TIMEOUT,
$exception,
true
);
}
}
......@@ -52,7 +52,7 @@ class behat_form_editor extends behat_form_field {
// We want the editor to be ready, otherwise the value can not
// be set and an exception is thrown.
for ($i = 0; $i < behat_base::WAIT_FOR_EDITOR_RETRIES; $i++) {
for ($i = 0; $i < behat_base::EXTENDED_TIMEOUT; $i++) {
try {
// Get tinyMCE editor id if it exists.
if ($editorid = $this->get_editor_id()) {
......@@ -95,7 +95,7 @@ class behat_form_editor extends behat_form_field {
// We want the editor to be ready to return the correct value, sometimes the
// page loads too fast and the returned value may be '' if the editor didn't
// have enough time to load completely despite having a different value.
for ($i = 0; $i < behat_base::WAIT_FOR_EDITOR_RETRIES; $i++) {
for ($i = 0; $i < behat_base::EXTENDED_TIMEOUT; $i++) {
try {
// Get tinyMCE editor id if it exists.
......@@ -137,7 +137,7 @@ class behat_form_editor extends behat_form_field {
* can not execute Javascript, also some Moodle settings disables the HTML
* editor.
*
* @return mixed The id of the editor of false if is not available
* @return mixed The id of the editor of false if it is not available
*/
protected function get_editor_id() {
......@@ -145,7 +145,7 @@ class behat_form_editor extends behat_form_field {
try {
$available = $this->session->evaluateScript('return (typeof tinyMCE != "undefined")');
// Also checking that it exist a tinyMCE editor for the requested field.
// Also checking that it exists a tinyMCE editor for the requested field.
$editorid = $this->field->getAttribute('id');
$available = $this->session->evaluateScript('return (typeof tinyMCE.get("'.$editorid.'") != "undefined")');
......
......@@ -68,6 +68,7 @@ class behat_deprecated extends behat_base {
// Looking for the element DOM node inside the specified row.
list($selector, $locator) = $this->transform_selector($selectortype, $element);
$elementnode = $this->find($selector, $locator, false, $rownode);
$this->ensure_element_is_visible($elementnode);
$elementnode->click();
}
......
......@@ -219,6 +219,21 @@ class behat_general extends behat_base {
$this->getSession()->wait(self::TIMEOUT * 1000, self::PAGE_READY_JS);
}
/**
* Waits until the editors are all completely loaded.
*
* @Given /^I wait until the editors are loaded$/
* @throws DriverException
*/
public function wait_until_editors_are_loaded() {
if (!$this->running_javascript()) {
throw new DriverException('Editors are not loaded when running without Javascript support');
}
$this->ensure_editors_are_loaded();
}
/**
* Waits until the provided element selector exists in the DOM
*
......@@ -348,6 +363,9 @@ class behat_general extends behat_base {
/**
* Checks, that the specified element is not visible. Only available in tests using Javascript.
*
* As a "not" method, it's performance is not specially good as we should ensure that the element
* have time to appear.
*
* @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" should not be visible$/
* @throws ElementNotFoundException
* @throws ExpectationException
......@@ -431,24 +449,35 @@ class behat_general extends behat_base {
$xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
"[count(descendant::*[contains(., $xpathliteral)]) = 0]";
// Wait until it finds the text, otherwise custom exception.
try {
$nodes = $this->find_all('xpath', $xpath);
} catch (ElementNotFoundException $e) {
throw new ExpectationException('"' . $text . '" text was not found in the page', $this->getSession());
}
// We also check for the element visibility when running JS tests.
if ($this->running_javascript()) {
foreach ($nodes as $node) {
// If we are not running javascript we have enough with the
// element existing as we can't check if it is visible.
if (!$this->running_javascript()) {
return;
}
// We spin as we don't have enough checking that the element is there, we
// should also ensure that the element is visible.
$this->spin(
function($context, $args) {
foreach ($args['nodes'] as $node) {
if ($node->isVisible()) {
return;
return true;
}
}
throw new ExpectationException("'{$text}' text was found but was not visible", $this->getSession());
}
// If non of the nodes is visible we loop again.
throw new ExpectationException('"' . $args['text'] . '" text was found but was not visible', $context->getSession());
},
array('nodes' => $nodes, 'text' => $text)
);
} catch (ElementNotFoundException $e) {
throw new ExpectationException('"' . $text . '" text was not found in the page', $this->getSession());
}
}
/**
......@@ -460,16 +489,43 @@ class behat_general extends behat_base {
*/
public function assert_page_not_contains_text($text) {
// Delegating the process to assert_page_contains_text.
// Looking for all the matching nodes without any other descendant matching the
// same xpath (we are using contains(., ....).
$xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text);
$xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
"[count(descendant::*[contains(., $xpathliteral)]) = 0]";
// We should wait a while to ensure that the page is not still loading elements.
// Giving preference to the reliability of the results rather than to the performance.
try {
$this->assert_page_contains_text($text);
} catch (ExpectationException $e) {
// It should not appear, so this is good.
$nodes = $this->find_all('xpath', $xpath);
} catch (ElementNotFoundException $e) {
// All ok.
return;
}
// If the page contains the text this is failing.
throw new ExpectationException('"' . $text . '" text was found in the page', $this->getSession());
// If we are not running javascript we have enough with the
// element existing as we can't check if it is hidden.
if (!$this->running_javascript()) {
throw new ExpectationException('"' . $text . '" text was found in the page', $this->getSession());
}
// If the element is there we should be sure that it is not visible.
$this->spin(
function($context, $args) {
foreach ($args['nodes'] as $node) {
if ($node->isVisible()) {
throw new ExpectationException('"' . $args['text'] . '" text was found in the page', $context->getSession());
}
}
// If non of the found nodes is visible we consider that the text is not visible.
return true;
},
array('nodes' => $nodes, 'text' => $text)
);
}
/**
......@@ -496,22 +552,30 @@ class behat_general extends behat_base {
// Wait until it finds the text inside the container, otherwise custom exception.
try {
$nodes = $this->find_all('xpath', $xpath, false, $container);
} catch (ElementNotFoundException $e) {
throw new ExpectationException('"' . $text . '" text was not found in the "' . $element . '" element', $this->getSession());
}
// We also check for the element visibility when running JS tests.
if ($this->running_javascript()) {
foreach ($nodes as $node) {
// If we are not running javascript we have enough with the
// element existing as we can't check if it is visible.
if (!$this->running_javascript()) {
return;
}
// We also check the element visibility when running JS tests.
$this->spin(
function($context, $args) {
foreach ($args['nodes'] as $node) {
if ($node->isVisible()) {
return;
return true;
}
}
throw new ExpectationException("'{$text}' text was found in the {$element} element but was not visible", $this->getSession());
}
} catch (ElementNotFoundException $e) {
throw new ExpectationException('"' . $text . '" text was not found in the ' . $element . ' element', $this->getSession());
}
throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element but was not visible', $context->getSession());
},
array('nodes' => $nodes, 'text' => $text, 'element' => $element)
);
}
/**
......@@ -526,18 +590,45 @@ class behat_general extends behat_base {
*/
public function assert_element_not_contains_text($text, $element, $selectortype) {
// Delegating the process to assert_element_contains_text.
// Getting the container where the text should be found.
$container = $this->get_selected_node($selectortype, $element);
// Looking for all the matching nodes without any other descendant matching the
// same xpath (we are using contains(., ....).
$xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text);
$xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
"[count(descendant::*[contains(., $xpathliteral)]) = 0]";
// We should wait a while to ensure that the page is not still loading elements.
// Giving preference to the reliability of the results rather than to the performance.
try {
$this->assert_element_contains_text($text, $element, $selectortype);
} catch (ExpectationException $e) {
// It should not appear, so this is good.
// We only catch ExpectationException as ElementNotFoundException
// will be thrown if the container does not exist.
$nodes = $this->find_all('xpath', $xpath, false, $container);
} catch (ElementNotFoundException $e) {
// All ok.
return;
}
// If the element contains the text this is failing.
throw new ExpectationException('"' . $text . '" text was found in the ' . $element . ' element', $this->getSession());
// If we are not running javascript we have enough with the
// element not being found as we can't check if it is visible.
if (!$this->running_javascript()) {
throw new ExpectationException('"' . $text . '" text was found in the "' . $element . '" element', $this->getSession());
}
// We need to ensure all the found nodes are hidden.
$this->spin(
function($context, $args) {
foreach ($args['nodes'] as $node) {
if ($node->isVisible()) {
throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element', $context->getSession());
}
}
// If all the found nodes are hidden we are happy.
return true;
},
array('nodes' => $nodes, 'text' => $text, 'element' => $element)
);
}
/**
......
......@@ -255,8 +255,9 @@ class behat_hooks extends behat_base {
*/
protected function wait_for_pending_js() {
// Wait for all pending JS to complete (max 10 seconds).
for ($i = 0; $i < 100; $i++) {
// We don't use behat_base::spin() here as we don't want to end up with an exception
// if the page & JSs don't finish loading properly.
for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
$pending = '';
try {
$jscode = 'return ' . self::PAGE_READY_JS . ' ? "" : M.util.pending_js.join(":");';
......@@ -281,8 +282,7 @@ class behat_hooks extends behat_base {
}
// Timeout waiting for JS to complete.
// We could throw an exception here - as this is a likely indicator of slow JS or JS errors.
echo ' Slow JS, pending requests:' . $pending . ' ';
// TODO MDL-43173 We should fail the scenarios if JS loading times out.
return false;
}
......
......@@ -76,6 +76,7 @@ class behat_navigation extends behat_base {