DrupalSelenium2Driver.php

Same filename and directory in other branches
  1. 9 core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php
  2. 8.9.x core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php
  3. 10 core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php

Namespace

Drupal\FunctionalJavascriptTests

File

core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php

View source
<?php

declare (strict_types=1);
namespace Drupal\FunctionalJavascriptTests;

use Behat\Mink\Driver\Selenium2Driver;
use Behat\Mink\Exception\DriverException;
use WebDriver\Element;
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 {
                $element = $this->getWebDriverSession()
                    ->element('xpath', $xpath);
                // \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->executeJsOnElement($element, <<<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
);
                if (!is_string($value) && strtolower($element->name()) === 'input' && in_array(strtolower($element->attribute('type')), [
                    'text',
                    'number',
                    'radio',
                ], TRUE)) {
                    // @todo Trigger deprecation in
                    //   https://www.drupal.org/project/drupal/issues/3421105.
                    $value = (string) $value;
                }
                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
        }
    }
    
    /**
     * Executes JS on a given element.
     *
     * @param \WebDriver\Element $element
     *   The webdriver element.
     * @param string $script
     *   The script to execute.
     *
     * @return mixed
     *   The result of executing the script.
     */
    private function executeJsOnElement(Element $element, string $script) {
        $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script);
        $options = [
            'script' => $script,
            'args' => [
                $element,
            ],
        ];
        return $this->getWebDriverSession()
            ->execute($options);
    }

}

Classes

Title Deprecated Summary
DrupalSelenium2Driver Provides a driver for Selenium testing.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.