Files
roundcubemail/tests/Browser/Browser.php
Pablo Zmdl 85094706f9 Docker compose for testing (#9525)
* More ignore-patterns in eslint config

So we don't have to specify them on the command line when we check
codestyle locally.

* Test setup for local and CI using containers

It uses standalone containers for the greenmail IMAP server and the
standalone browser.
A testrunner image is built in the CI (for `linux/amd64` only, because
Github doesn't support multi-platform building on their default
runners and we don't have our own.)

This setup helps to run the tests (reproduceably) also locally.
Previously, on my machine, they produced varying results.
It also reduces the dependencies for running the browser test.
Local execution only depends on `docker compose`, no other tools
(previously it required `sudo`, `java`, and some more.)

The previous solution should still work, if you want it.

The scripts are stored in a directory called `.ci` to hide them a little
and avoid confusion with the container images from the
`roundcubemail-docker` repo.

* Fix UI tests by waiting for element before using it

This only was a flaky problem only occurring sometimes.

* Force a new IMAP connection in plugin tests, too

In other code the initial connection is forced. Doing this here, too,
fixes occasional problems with lost imap connections.

* Make waiting for zipfile's content more robust

* CI: Run tests from script on Windows, too

* CI: Do start local chrome if no connect URL is given

* Move compose.yml to tests/

This way it's less easy mistaken as usable for running Roundcubemail in
production.

* Move compose.yml to .ci/
2024-07-22 11:44:09 +02:00

353 lines
8.8 KiB
PHP

<?php
namespace Roundcube\Tests\Browser;
use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\StaleElementReferenceException;
use Facebook\WebDriver\Exception\TimeoutException;
use Facebook\WebDriver\WebDriverKeys;
use PHPUnit\Framework\Assert;
/**
* Laravel Dusk Browser extensions
*/
class Browser extends \Laravel\Dusk\Browser
{
/**
* Assert number of (visible) elements
*/
public function assertElementsCount($selector, $expected_count, $visible = true)
{
$elements = $this->elements($selector);
$count = count($elements);
if ($visible) {
foreach ($elements as $element) {
if (!$element->isDisplayed()) {
$count--;
}
}
}
Assert::assertEquals($expected_count, $count);
return $this;
}
/**
* Assert specified rcmail.env value
*/
public function assertEnvEquals($key, $expected)
{
Assert::assertEquals($expected, $this->getEnv($key));
return $this;
}
/**
* Assert specified checkbox state
*/
public function assertCheckboxState($selector, $state)
{
if ($state) {
$this->assertChecked($selector);
} else {
$this->assertNotChecked($selector);
}
return $this;
}
/**
* Assert that the given element has specified class assigned.
*/
public function assertHasClass($selector, $class_name)
{
$fullSelector = $this->resolver->format($selector);
$element = $this->resolver->findOrFail($selector);
$classes = explode(' ', (string) $element->getAttribute('class'));
Assert::assertContains($class_name, $classes);
return $this;
}
/**
* Assert Task menu state
*/
public function assertTaskMenu($selected)
{
$this->with(new Components\Taskmenu(), static function ($browser) use ($selected) {
$browser->assertMenuState($selected);
});
return $this;
}
/**
* Assert toolbar menu state
*/
public function assertToolbarMenu($active, $disabled = [], $missing = [])
{
$this->with(new Components\Toolbarmenu(), static function ($browser) use ($active, $disabled, $missing) {
$browser->assertMenuState($active, $disabled, $missing);
});
return $this;
}
/**
* Close toolbar menu (on phones)
*/
public function closeToolbarMenu()
{
$this->with(new Components\Toolbarmenu(), static function ($browser) {
$browser->closeMenu();
});
return $this;
}
/**
* Select taskmenu item
*/
public function clickTaskMenuItem($name)
{
$this->with(new Components\Taskmenu(), static function ($browser) use ($name) {
$browser->clickMenuItem($name);
});
return $this;
}
/**
* Select toolbar menu item
*/
public function clickToolbarMenuItem($name, $dropdown_action = null, $close = true)
{
$this->with(new Components\Toolbarmenu(), static function ($browser) use ($name, $dropdown_action, $close) {
$browser->clickMenuItem($name, $dropdown_action, $close);
});
return $this;
}
/**
* Shortcut to click an element while holding CTRL key
*/
public function ctrlClick($selector)
{
$this->driver->getKeyboard()->pressKey(WebDriverKeys::LEFT_CONTROL);
$this->element($selector)->click();
$this->driver->getKeyboard()->releaseKey(WebDriverKeys::LEFT_CONTROL);
return $this;
}
/**
* Return rcmail.env entry
*/
public function getEnv($key)
{
return $this->driver->executeScript("return rcmail.env['{$key}']");
}
/**
* Visit specified task/action with logon if needed
*/
public function go($task = 'mail', $action = null, $login = true)
{
$this->with(new Components\App(), static function ($browser) use ($task, $action, $login) {
$browser->gotoAction($task, $action, $login);
});
return $this;
}
/**
* Check if in Phone mode
*/
public static function isPhone()
{
return getenv('TESTS_MODE') == 'phone';
}
/**
* Check if in Tablet mode
*/
public static function isTablet()
{
return getenv('TESTS_MODE') == 'tablet';
}
/**
* Check if in Desktop mode
*/
public static function isDesktop()
{
return !self::isPhone() && !self::isTablet();
}
/**
* Handler for actions that expect to open a new window
*
* @param callable $callback Function to execute with Browser object as argument
*
* @return array Main window handle and new window handle
*/
public function openWindow($callback)
{
$current_window = $this->driver->getWindowHandle();
$before_handles = $this->driver->getWindowHandles();
$callback($this);
$after_handles = $this->driver->getWindowHandles();
$new_window = array_first(array_diff($after_handles, $before_handles));
return [$current_window, $new_window];
}
/**
* Change state of the Elastic's pretty checkbox
*/
public function setCheckboxState($selector, $state)
{
// Because you can't operate on the original checkbox directly
$this->ensurejQueryIsAvailable();
if ($state) {
$run = "if (!element.prev().is(':checked')) element.click()";
} else {
$run = "if (element.prev().is(':checked')) element.click()";
}
$this->script(
"var element = jQuery('{$selector}')[0] || jQuery('input[name={$selector}]')[0];"
. "element = jQuery(element).next('.custom-control-label'); {$run};"
);
return $this;
}
/**
* Returns content of a downloaded file
*/
public function readDownloadedFile($filename)
{
$filename = $this->getDownloadedFilePath($filename);
// Give the browser a chance to finish download
$n = 0;
while (!file_exists($filename)) {
if ($n++ > 5) {
break;
}
sleep(1);
}
Assert::assertFileExists($filename);
return file_get_contents($filename);
}
/**
* Removes downloaded file
*/
public function removeDownloadedFile($filename)
{
@unlink($this->getDownloadedFilePath($filename));
return $this;
}
/**
* Close UI (notice/confirmation/loading/error/warning) message
*/
public function closeMessage($type)
{
$selector = '#messagestack > div.' . $type;
$this->click($selector);
return $this;
}
/**
* Wait for the given selector to be removed.
*
* @param string $selector
* @param int|null $seconds
*
* @return $this
*
* @throws TimeoutException
*/
public function waitUntilMissingOrStale($selector, $seconds = null)
{
$message = $this->formatTimeOutMessage('Waited %s seconds for removal of selector', $selector);
return $this->waitUsing($seconds, 100, function () use ($selector) {
try {
$missing = !$this->resolver->findOrFail($selector)->isDisplayed();
} catch (NoSuchElementException $e) {
$missing = true;
} catch (StaleElementReferenceException $e) {
$missing = true;
}
return $missing;
}, $message);
}
/**
* Wait until the UI is unlocked
*/
public function waitUntilNotBusy()
{
$this->waitUntil('!rcmail.busy');
return $this;
}
/**
* Wait for UI (notice/confirmation/loading/error/warning) message
* and assert it's text
*/
public function waitForMessage($type, $text)
{
$selector = '#messagestack > div.' . $type;
$this->waitFor($selector)->assertSeeIn($selector, $text);
return $this;
}
/**
* Execute code within body context.
* Useful to execute code that selects elements outside of a component context
*/
public function withinBody($callback)
{
if ($this->resolver->prefix != 'body') {
$orig_prefix = $this->resolver->prefix;
$this->resolver->prefix = 'body';
}
call_user_func($callback, $this);
if (isset($orig_prefix)) {
$this->resolver->prefix = $orig_prefix;
}
return $this;
}
public function getDownloadedFilePath($filename)
{
$basedir = getenv('TESTRUNNER_DOWNLOADS_DIR') ?: TESTS_DIR . 'downloads';
$basedir = rtrim($basedir, \DIRECTORY_SEPARATOR);
return $basedir . \DIRECTORY_SEPARATOR . $filename;
}
}