forked from a64f7bb4-7358-4778-9fbe-3b882c34cc1d/v1
233 lines
7.1 KiB
PHP
233 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace Drupal\FunctionalJavascriptTests;
|
|
|
|
use Behat\Mink\Driver\Selenium2Driver;
|
|
use Behat\Mink\Exception\DriverException;
|
|
use WebDriver\Exception;
|
|
use WebDriver\Exception\UnknownError;
|
|
use WebDriver\ServiceFactory;
|
|
|
|
/**
|
|
* Provides a driver for Selenium testing.
|
|
*/
|
|
class DrupalSelenium2Driver extends Selenium2Driver {
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function __construct($browserName = 'firefox', $desiredCapabilities = NULL, $wdHost = 'http://localhost:4444/wd/hub') {
|
|
parent::__construct($browserName, $desiredCapabilities, $wdHost);
|
|
ServiceFactory::getInstance()->setServiceClass('service.curl', WebDriverCurlService::class);
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function setCookie($name, $value = NULL) {
|
|
if ($value === NULL) {
|
|
$this->getWebDriverSession()->deleteCookie($name);
|
|
return;
|
|
}
|
|
|
|
$cookieArray = [
|
|
'name' => $name,
|
|
'value' => urlencode($value),
|
|
'secure' => FALSE,
|
|
// Unlike \Behat\Mink\Driver\Selenium2Driver::setCookie we set a domain
|
|
// and an expire date, as otherwise cookies leak from one test site into
|
|
// another.
|
|
'domain' => parse_url($this->getWebDriverSession()->url(), PHP_URL_HOST),
|
|
'expires' => time() + 80000,
|
|
];
|
|
|
|
$this->getWebDriverSession()->setCookie($cookieArray);
|
|
}
|
|
|
|
/**
|
|
* Uploads a file to the Selenium instance and returns the remote path.
|
|
*
|
|
* \Behat\Mink\Driver\Selenium2Driver::uploadFile() is a private method so
|
|
* that can't be used inside a test, but we need the remote path that is
|
|
* generated when uploading to make sure the file reference exists on the
|
|
* container running selenium.
|
|
*
|
|
* @param string $path
|
|
* The path to the file to upload.
|
|
*
|
|
* @return string
|
|
* The remote path.
|
|
*
|
|
* @throws \Behat\Mink\Exception\DriverException
|
|
* When PHP is compiled without zip support, or the file doesn't exist.
|
|
* @throws \WebDriver\Exception\UnknownError
|
|
* When an unknown error occurred during file upload.
|
|
* @throws \Exception
|
|
* When a known error occurred during file upload.
|
|
*/
|
|
public function uploadFileAndGetRemoteFilePath($path) {
|
|
if (!is_file($path)) {
|
|
throw new DriverException('File does not exist locally and cannot be uploaded to the remote instance.');
|
|
}
|
|
|
|
if (!class_exists('ZipArchive')) {
|
|
throw new DriverException('Could not compress file, PHP is compiled without zip support.');
|
|
}
|
|
|
|
// Selenium only accepts uploads that are compressed as a Zip archive.
|
|
$tempFilename = tempnam('', 'WebDriverZip');
|
|
|
|
$archive = new \ZipArchive();
|
|
$result = $archive->open($tempFilename, \ZipArchive::OVERWRITE);
|
|
if (!$result) {
|
|
throw new DriverException('Zip archive could not be created. Error ' . $result);
|
|
}
|
|
$result = $archive->addFile($path, basename($path));
|
|
if (!$result) {
|
|
throw new DriverException('File could not be added to zip archive.');
|
|
}
|
|
$result = $archive->close();
|
|
if (!$result) {
|
|
throw new DriverException('Zip archive could not be closed.');
|
|
}
|
|
|
|
try {
|
|
$remotePath = $this->getWebDriverSession()->file(['file' => base64_encode(file_get_contents($tempFilename))]);
|
|
|
|
// If no path is returned the file upload failed silently.
|
|
if (empty($remotePath)) {
|
|
throw new UnknownError();
|
|
}
|
|
}
|
|
catch (\Exception $e) {
|
|
throw $e;
|
|
}
|
|
finally {
|
|
unlink($tempFilename);
|
|
}
|
|
|
|
return $remotePath;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function click($xpath) {
|
|
/** @var \Exception $not_clickable_exception */
|
|
$not_clickable_exception = NULL;
|
|
$result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath) {
|
|
try {
|
|
parent::click($xpath);
|
|
return TRUE;
|
|
}
|
|
catch (Exception $exception) {
|
|
if (!JSWebAssert::isExceptionNotClickable($exception)) {
|
|
// Rethrow any unexpected exceptions.
|
|
throw $exception;
|
|
}
|
|
$not_clickable_exception = $exception;
|
|
return NULL;
|
|
}
|
|
});
|
|
if ($result !== TRUE) {
|
|
throw $not_clickable_exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function setValue($xpath, $value) {
|
|
/** @var \Exception $not_clickable_exception */
|
|
$not_clickable_exception = NULL;
|
|
$result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath, $value) {
|
|
try {
|
|
// \Behat\Mink\Driver\Selenium2Driver::setValue() will call .blur() on
|
|
// the element, modify that to trigger the "input" and "change" events
|
|
// instead. They indicate the value has changed, rather than implying
|
|
// user focus changes. This script only runs when Drupal javascript has
|
|
// been loaded.
|
|
$this->executeJsOnXpath($xpath, <<<JS
|
|
if (typeof Drupal !== 'undefined') {
|
|
var node = {{ELEMENT}};
|
|
var original = node.blur;
|
|
node.blur = function() {
|
|
node.dispatchEvent(new Event("input", {bubbles:true}));
|
|
node.dispatchEvent(new Event("change", {bubbles:true}));
|
|
// Do not wait for the debounce, which only triggers the 'formUpdated` event
|
|
// up to once every 0.3 seconds. In tests, no humans are typing, hence there
|
|
// is no need to debounce.
|
|
// @see Drupal.behaviors.formUpdated
|
|
node.dispatchEvent(new Event("formUpdated", {bubbles:true}));
|
|
node.blur = original;
|
|
};
|
|
}
|
|
JS);
|
|
parent::setValue($xpath, $value);
|
|
return TRUE;
|
|
}
|
|
catch (Exception $exception) {
|
|
if (!JSWebAssert::isExceptionNotClickable($exception) && !str_contains($exception->getMessage(), 'invalid element state')) {
|
|
// Rethrow any unexpected exceptions.
|
|
throw $exception;
|
|
}
|
|
$not_clickable_exception = $exception;
|
|
return NULL;
|
|
}
|
|
});
|
|
if ($result !== TRUE) {
|
|
throw $not_clickable_exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits for a callback to return a truthy result and returns it.
|
|
*
|
|
* @param int|float $timeout
|
|
* Maximal allowed waiting time in seconds.
|
|
* @param callable $callback
|
|
* Callback, which result is both used as waiting condition and returned.
|
|
* Will receive reference to `this driver` as first argument.
|
|
*
|
|
* @return mixed
|
|
* The result of the callback.
|
|
*/
|
|
private function waitFor($timeout, callable $callback) {
|
|
$start = microtime(TRUE);
|
|
$end = $start + $timeout;
|
|
|
|
do {
|
|
$result = call_user_func($callback, $this);
|
|
|
|
if ($result) {
|
|
break;
|
|
}
|
|
|
|
usleep(10000);
|
|
} while (microtime(TRUE) < $end);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function dragTo($sourceXpath, $destinationXpath) {
|
|
// Ensure both the source and destination exist at this point.
|
|
$this->getWebDriverSession()->element('xpath', $sourceXpath);
|
|
$this->getWebDriverSession()->element('xpath', $destinationXpath);
|
|
|
|
try {
|
|
parent::dragTo($sourceXpath, $destinationXpath);
|
|
}
|
|
catch (Exception $e) {
|
|
// Do not care if this fails for any reason. It is a source of random
|
|
// fails. The calling code should be doing assertions on the results of
|
|
// dragging anyway. See upstream issues:
|
|
// - https://github.com/minkphp/MinkSelenium2Driver/issues/97
|
|
// - https://github.com/minkphp/MinkSelenium2Driver/issues/51
|
|
}
|
|
}
|
|
|
|
}
|