* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Behat\Mink\Driver; use Behat\Mink\Exception\DriverException; use Behat\Mink\KeyModifier; use Behat\Mink\Selector\Xpath\Escaper; use WebDriver\Element; use WebDriver\Exception\NoSuchElement; use WebDriver\Exception\StaleElementReference; use WebDriver\Exception\UnknownCommand; use WebDriver\Exception\UnknownError; use WebDriver\Key; use WebDriver\Session; use WebDriver\WebDriver; use WebDriver\Window; /** * Selenium2 driver. * * @author Pete Otaqui */ class Selenium2Driver extends CoreDriver { /** * Whether the browser has been started * @var bool */ private $started = false; /** * The WebDriver instance * @var WebDriver */ private $webDriver; /** * @var string */ private $browserName; /** * @var array */ private $desiredCapabilities; /** * The WebDriverSession instance * @var Session|null */ private $wdSession; /** * The timeout configuration * @var array{script?: int, implicit?: int, page?: int} */ private $timeouts = array(); /** * @var Escaper */ private $xpathEscaper; /** * Instantiates the driver. * * @param string $browserName Browser name * @param array|null $desiredCapabilities The desired capabilities * @param string $wdHost The WebDriver host */ public function __construct(string $browserName = 'firefox', ?array $desiredCapabilities = null, string $wdHost = 'http://localhost:4444/wd/hub') { $this->setBrowserName($browserName); $this->setDesiredCapabilities($desiredCapabilities); $this->setWebDriver(new WebDriver($wdHost)); $this->xpathEscaper = new Escaper(); } /** * Sets the browser name * * @param string $browserName the name of the browser to start, default is 'firefox' * * @return void */ protected function setBrowserName(string $browserName = 'firefox') { $this->browserName = $browserName; } /** * Sets the desired capabilities - called on construction. If null is provided, will set the * defaults as desired. * * See http://code.google.com/p/selenium/wiki/DesiredCapabilities * * @param array|null $desiredCapabilities an array of capabilities to pass on to the WebDriver server * * @return void * * @throws DriverException */ public function setDesiredCapabilities(?array $desiredCapabilities = null) { if ($this->started) { throw new DriverException("Unable to set desiredCapabilities, the session has already started"); } if (null === $desiredCapabilities) { $desiredCapabilities = array(); } // Join $desiredCapabilities with defaultCapabilities $desiredCapabilities = array_replace(self::getDefaultCapabilities(), $desiredCapabilities); if (isset($desiredCapabilities['firefox'])) { foreach ($desiredCapabilities['firefox'] as $capability => $value) { switch ($capability) { case 'profile': $fileContents = file_get_contents($value); if ($fileContents === false) { throw new DriverException(sprintf('Could not read the profile file "%s".', $value)); } $desiredCapabilities['firefox_'.$capability] = base64_encode($fileContents); break; default: $desiredCapabilities['firefox_'.$capability] = $value; } } unset($desiredCapabilities['firefox']); } // See https://sites.google.com/a/chromium.org/chromedriver/capabilities if (isset($desiredCapabilities['chrome'])) { $chromeOptions = (isset($desiredCapabilities['goog:chromeOptions']) && is_array($desiredCapabilities['goog:chromeOptions']))? $desiredCapabilities['goog:chromeOptions']:array(); foreach ($desiredCapabilities['chrome'] as $capability => $value) { if ($capability == 'switches') { $chromeOptions['args'] = $value; } else { $chromeOptions[$capability] = $value; } $desiredCapabilities['chrome.'.$capability] = $value; } $desiredCapabilities['goog:chromeOptions'] = $chromeOptions; unset($desiredCapabilities['chrome']); } $this->desiredCapabilities = $desiredCapabilities; } /** * Gets the desiredCapabilities * * @return array */ public function getDesiredCapabilities() { return $this->desiredCapabilities; } /** * Sets the WebDriver instance * * @param WebDriver $webDriver An instance of the WebDriver class * * @return void */ public function setWebDriver(WebDriver $webDriver) { $this->webDriver = $webDriver; } /** * Gets the WebDriverSession instance * * @return Session * * @throws DriverException if the session is not started */ public function getWebDriverSession() { if ($this->wdSession === null) { throw new DriverException('The driver is not started.'); } return $this->wdSession; } /** * Returns the default capabilities * * @return array */ public static function getDefaultCapabilities() { return array( 'browserName' => 'firefox', 'name' => 'Behat Test', ); } /** * Makes sure that the Syn event library has been injected into the current page, * and return $this for a fluid interface, * * $this->withSyn()->executeJsOnXpath($xpath, $script); * * @return Selenium2Driver * * @throws DriverException */ protected function withSyn() { $hasSyn = $this->getWebDriverSession()->execute(array( 'script' => 'return window.syn !== undefined && window.syn.trigger !== undefined', 'args' => array() )); if (!$hasSyn) { $synJs = file_get_contents(__DIR__.'/Resources/syn.js'); \assert($synJs !== false); $this->getWebDriverSession()->execute(array( 'script' => $synJs, 'args' => array() )); } return $this; } /** * Creates some options for key events * * @param string|int $char the character or code * @param KeyModifier::*|null $modifier * * @return string a json encoded options array for Syn * * @throws DriverException */ protected static function charToOptions($char, ?string $modifier = null) { if (is_int($char)) { $charCode = $char; $char = chr($charCode); } else { $charCode = ord($char); } $options = array( 'key' => $char, 'which' => $charCode, 'charCode' => $charCode, 'keyCode' => $charCode, ); if ($modifier) { $options[$modifier . 'Key'] = true; } $json = json_encode($options); if ($json === false) { throw new DriverException('Failed to encode options: ' . json_last_error_msg()); } return $json; } /** * Executes JS on a given element - pass in a js script string and {{ELEMENT}} will * be replaced with a reference to the result of the $xpath query * * @example $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.childNodes.length'); * * @param string $xpath the xpath to search with * @param string $script the script to execute * @param bool $sync whether to run the script synchronously (default is TRUE) * * @return mixed * * @throws DriverException */ protected function executeJsOnXpath(string $xpath, string $script, bool $sync = true) { return $this->executeJsOnElement($this->findElement($xpath), $script, $sync); } /** * Executes JS on a given element - pass in a js script string and {{ELEMENT}} will * be replaced with a reference to the element * * @example $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.childNodes.length'); * * @param Element $element the webdriver element * @param string $script the script to execute * @param bool $sync whether to run the script synchronously (default is TRUE) * * @return mixed */ private function executeJsOnElement(Element $element, string $script, bool $sync = true) { $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script); $options = array( 'script' => $script, 'args' => array($element), ); if ($sync) { return $this->getWebDriverSession()->execute($options); } return $this->getWebDriverSession()->execute_async($options); } public function start() { try { $this->wdSession = $this->webDriver->session($this->browserName, $this->desiredCapabilities); } catch (\Exception $e) { throw new DriverException('Could not open connection: '.$e->getMessage(), 0, $e); } $this->started = true; $this->applyTimeouts(); } /** * Sets the timeouts to apply to the webdriver session * * @param array{script?: int, implicit?: int, page?: int} $timeouts times are in milliseconds * * @return void * * @throws DriverException */ public function setTimeouts(array $timeouts) { $this->timeouts = $timeouts; if ($this->isStarted()) { $this->applyTimeouts(); } } /** * Applies timeouts to the current session */ private function applyTimeouts(): void { try { foreach ($this->timeouts as $type => $param) { $this->getWebDriverSession()->timeouts($type, $param); } } catch (UnknownError $e) { throw new DriverException('Error setting timeout: ' . $e->getMessage(), 0, $e); } } public function isStarted() { return $this->started; } public function stop() { if (!$this->wdSession) { throw new DriverException('Could not connect to a Selenium 2 / WebDriver server'); } $this->started = false; try { $this->wdSession->close(); } catch (\Exception $e) { throw new DriverException('Could not close connection', 0, $e); } } public function reset() { $this->getWebDriverSession()->deleteAllCookies(); } public function visit(string $url) { $this->getWebDriverSession()->open($url); } public function getCurrentUrl() { return $this->getWebDriverSession()->url(); } public function reload() { $this->getWebDriverSession()->refresh(); } public function forward() { $this->getWebDriverSession()->forward(); } public function back() { $this->getWebDriverSession()->back(); } public function switchToWindow(?string $name = null) { $this->getWebDriverSession()->focusWindow($name ?: ''); } public function switchToIFrame(?string $name = null) { $this->getWebDriverSession()->frame(array('id' => $name)); } public function setCookie(string $name, ?string $value = null) { if (null === $value) { $this->getWebDriverSession()->deleteCookie($name); return; } // PHP 7.4 changed the way it encodes cookies to better respect the spec. // This assumes that the server and the Mink client run on the same version (or // at least the same side of the behavior change), so that the server and Mink // consider the same value. if (\PHP_VERSION_ID >= 70400) { $encodedValue = rawurlencode($value); } else { $encodedValue = urlencode($value); } $cookieArray = array( 'name' => $name, 'value' => $encodedValue, 'secure' => false, // thanks, chibimagic! ); $this->getWebDriverSession()->setCookie($cookieArray); } public function getCookie(string $name) { $cookies = $this->getWebDriverSession()->getAllCookies(); foreach ($cookies as $cookie) { if ($cookie['name'] === $name) { // PHP 7.4 changed the way it encodes cookies to better respect the spec. // This assumes that the server and the Mink client run on the same version (or // at least the same side of the behavior change), so that the server and Mink // consider the same value. if (\PHP_VERSION_ID >= 70400) { return rawurldecode($cookie['value']); } return urldecode($cookie['value']); } } return null; } public function getContent() { return $this->getWebDriverSession()->source(); } public function getScreenshot() { return base64_decode($this->getWebDriverSession()->screenshot()); } public function getWindowNames() { return $this->getWebDriverSession()->window_handles(); } public function getWindowName() { return $this->getWebDriverSession()->window_handle(); } /** * @protected */ public function findElementXpaths(string $xpath) { $nodes = $this->getWebDriverSession()->elements('xpath', $xpath); $elements = array(); foreach ($nodes as $i => $node) { $elements[] = sprintf('(%s)[%d]', $xpath, $i+1); } return $elements; } public function getTagName(string $xpath) { return $this->findElement($xpath)->name(); } public function getText(string $xpath) { $node = $this->findElement($xpath); $text = $node->text(); $text = (string) str_replace(array("\r", "\r\n", "\n"), ' ', $text); return $text; } public function getHtml(string $xpath) { return $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.innerHTML;'); } public function getOuterHtml(string $xpath) { return $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.outerHTML;'); } public function getAttribute(string $xpath, string $name) { $script = 'return {{ELEMENT}}.getAttribute(' . json_encode((string) $name) . ')'; return $this->executeJsOnXpath($xpath, $script); } public function getValue(string $xpath) { $element = $this->findElement($xpath); $elementName = strtolower($element->name()); $elementType = strtolower($element->attribute('type') ?: ''); // Getting the value of a checkbox returns its value if selected. if ('input' === $elementName && 'checkbox' === $elementType) { return $element->selected() ? $element->attribute('value') : null; } if ('input' === $elementName && 'radio' === $elementType) { $script = <<executeJsOnElement($element, $script); } // Using $element->attribute('value') on a select only returns the first selected option // even when it is a multiple select, so a custom retrieval is needed. if ('select' === $elementName && $element->attribute('multiple')) { $script = <<executeJsOnElement($element, $script); } return $element->attribute('value'); } public function setValue(string $xpath, $value) { $element = $this->findElement($xpath); $elementName = strtolower($element->name()); if ('select' === $elementName) { if (is_array($value)) { $this->deselectAllOptions($element); foreach ($value as $option) { $this->selectOptionOnElement($element, $option, true); } return; } if (\is_bool($value)) { throw new DriverException('Boolean values cannot be used for a select element.'); } $this->selectOptionOnElement($element, $value); return; } if ('input' === $elementName) { $elementType = strtolower($element->attribute('type') ?: ''); if (in_array($elementType, array('submit', 'image', 'button', 'reset'))) { throw new DriverException(sprintf('Impossible to set value an element with XPath "%s" as it is not a select, textarea or textbox', $xpath)); } if ('checkbox' === $elementType) { if ($element->selected() xor (bool) $value) { $this->clickOnElement($element); } return; } if ('radio' === $elementType) { if (!\is_string($value)) { throw new DriverException('Only string values can be used for a radio input.'); } $this->selectRadioValue($element, $value); return; } if ('file' === $elementType) { if (!\is_string($value)) { throw new DriverException('Only string values can be used for a file input.'); } $element->postValue(array('value' => array(strval($value)))); return; } } if (!\is_string($value)) { throw new DriverException(sprintf('Only string values can be used for a %s element.', $elementName)); } $value = strval($value); if (in_array($elementName, array('input', 'textarea'))) { $existingValueLength = strlen($element->attribute('value')); $value = str_repeat(Key::BACKSPACE . Key::DELETE, $existingValueLength) . $value; } $element->postValue(array('value' => array($value))); // Remove the focus from the element if the field still has focus in // order to trigger the change event. By doing this instead of simply // triggering the change event for the given xpath we ensure that the // change event will not be triggered twice for the same element if it // has lost focus in the meanwhile. If the element has lost focus // already then there is nothing to do as this will already have caused // the triggering of the change event for that element. $script = <<executeJsOnElement($element, $script); } catch (StaleElementReference $e) { // Do nothing because an element was already removed and therefore // blurring is not needed. } } public function check(string $xpath) { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'checkbox', 'check'); if ($element->selected()) { return; } $this->clickOnElement($element); } public function uncheck(string $xpath) { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'checkbox', 'uncheck'); if (!$element->selected()) { return; } $this->clickOnElement($element); } public function isChecked(string $xpath) { return $this->findElement($xpath)->selected(); } public function selectOption(string $xpath, string $value, bool $multiple = false) { $element = $this->findElement($xpath); $tagName = strtolower($element->name()); if ('input' === $tagName && 'radio' === strtolower($element->attribute('type') ?: '')) { $this->selectRadioValue($element, $value); return; } if ('select' === $tagName) { $this->selectOptionOnElement($element, $value, $multiple); return; } throw new DriverException(sprintf('Impossible to select an option on the element with XPath "%s" as it is not a select or radio input', $xpath)); } public function isSelected(string $xpath) { return $this->findElement($xpath)->selected(); } public function click(string $xpath) { $this->clickOnElement($this->findElement($xpath)); } private function clickOnElement(Element $element): void { try { // Move the mouse to the element as Selenium does not allow clicking on an element which is outside the viewport $this->getWebDriverSession()->moveto(array('element' => $element->getID())); } catch (UnknownCommand $e) { // If the Webdriver implementation does not support moveto (which is not part of the W3C WebDriver spec), proceed to the click } catch (UnknownError $e) { // Chromium driver sends back UnknownError (WebDriver\Exception with code 13) } $element->click(); } public function doubleClick(string $xpath) { $this->mouseOver($xpath); $this->getWebDriverSession()->doubleclick(); } public function rightClick(string $xpath) { $this->mouseOver($xpath); $this->getWebDriverSession()->click(array('button' => 2)); } public function attachFile(string $xpath, string $path) { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'file', 'attach a file on'); // Upload the file to Selenium and use the remote path. This will // ensure that Selenium always has access to the file, even if it runs // as a remote instance. try { $remotePath = $this->uploadFile($path); } catch (\Exception $e) { // File could not be uploaded to remote instance. Use the local path. $remotePath = $path; } $element->postValue(array('value' => array($remotePath))); } public function isVisible(string $xpath) { return $this->findElement($xpath)->displayed(); } public function mouseOver(string $xpath) { $this->getWebDriverSession()->moveto(array( 'element' => $this->findElement($xpath)->getID() )); } public function focus(string $xpath) { $this->trigger($xpath, 'focus'); } public function blur(string $xpath) { $this->trigger($xpath, 'blur'); } public function keyPress(string $xpath, $char, ?string $modifier = null) { $options = self::charToOptions($char, $modifier); $this->trigger($xpath, 'keypress', $options); } public function keyDown(string $xpath, $char, ?string $modifier = null) { $options = self::charToOptions($char, $modifier); $this->trigger($xpath, 'keydown', $options); } public function keyUp(string $xpath, $char, ?string $modifier = null) { $options = self::charToOptions($char, $modifier); $this->trigger($xpath, 'keyup', $options); } public function dragTo(string $sourceXpath, string $destinationXpath) { $source = $this->findElement($sourceXpath); $destination = $this->findElement($destinationXpath); $this->getWebDriverSession()->moveto(array( 'element' => $source->getID() )); $script = <<withSyn()->executeJsOnElement($source, $script); $this->getWebDriverSession()->buttondown(); $this->getWebDriverSession()->moveto(array( 'element' => $destination->getID() )); $this->getWebDriverSession()->buttonup(); $script = <<withSyn()->executeJsOnElement($destination, $script); } public function executeScript(string $script) { if (preg_match('/^function[\s\(]/', $script)) { $script = preg_replace('/;$/', '', $script); $script = '(' . $script . ')'; } $this->getWebDriverSession()->execute(array('script' => $script, 'args' => array())); } public function evaluateScript(string $script) { if (0 !== strpos(trim($script), 'return ')) { $script = 'return ' . $script; } return $this->getWebDriverSession()->execute(array('script' => $script, 'args' => array())); } public function wait(int $timeout, string $condition) { $script = 'return (' . rtrim($condition, " \t\n\r;") . ');'; $start = microtime(true); $end = $start + $timeout / 1000.0; do { $result = $this->getWebDriverSession()->execute(array('script' => $script, 'args' => array())); if ($result) { break; } usleep(10000); } while (microtime(true) < $end); return (bool) $result; } public function resizeWindow(int $width, int $height, ?string $name = null) { $window = $this->getWebDriverSession()->window($name ?: 'current'); \assert($window instanceof Window); $window->postSize( array('width' => $width, 'height' => $height) ); } public function submitForm(string $xpath) { $this->findElement($xpath)->submit(); } public function maximizeWindow(?string $name = null) { $window = $this->getWebDriverSession()->window($name ?: 'current'); \assert($window instanceof Window); $window->maximize(); } /** * Returns Session ID of WebDriver or `null`, when session not started yet. * * @return string|null */ public function getWebDriverSessionId() { return $this->wdSession !== null ? basename($this->wdSession->getUrl()) : null; } /** * @param string $xpath * * @return Element * * @throws DriverException */ private function findElement(string $xpath): Element { return $this->getWebDriverSession()->element('xpath', $xpath); } /** * Selects a value in a radio button group * * @param Element $element An element referencing one of the radio buttons of the group * @param string $value The value to select * * @throws DriverException when the value cannot be found */ private function selectRadioValue(Element $element, string $value): void { // short-circuit when we already have the right button of the group to avoid XPath queries if ($element->attribute('value') === $value) { $element->click(); return; } $name = $element->attribute('name'); if (!$name) { throw new DriverException(sprintf('The radio button does not have the value "%s"', $value)); } $formId = $element->attribute('form'); try { if (null !== $formId) { $xpath = <<<'XPATH' //form[@id=%1$s]//input[@type="radio" and not(@form) and @name=%2$s and @value = %3$s] | //input[@type="radio" and @form=%1$s and @name=%2$s and @value = %3$s] XPATH; $xpath = sprintf( $xpath, $this->xpathEscaper->escapeLiteral($formId), $this->xpathEscaper->escapeLiteral($name), $this->xpathEscaper->escapeLiteral($value) ); $input = $this->getWebDriverSession()->element('xpath', $xpath); } else { $xpath = sprintf( './ancestor::form//input[@type="radio" and not(@form) and @name=%s and @value = %s]', $this->xpathEscaper->escapeLiteral($name), $this->xpathEscaper->escapeLiteral($value) ); $input = $element->element('xpath', $xpath); } } catch (NoSuchElement $e) { $message = sprintf('The radio group "%s" does not have an option "%s"', $name, $value); throw new DriverException($message, 0, $e); } $input->click(); } /** * @throws DriverException */ private function selectOptionOnElement(Element $element, string $value, bool $multiple = false): void { $escapedValue = $this->xpathEscaper->escapeLiteral($value); // The value of an option is the normalized version of its text when it has no value attribute $optionQuery = sprintf('.//option[@value = %s or (not(@value) and normalize-space(.) = %s)]', $escapedValue, $escapedValue); $option = $element->element('xpath', $optionQuery); if ($multiple || !$element->attribute('multiple')) { if (!$option->selected()) { $option->click(); } return; } // Deselect all options before selecting the new one $this->deselectAllOptions($element); $option->click(); } /** * Deselects all options of a multiple select * * Note: this implementation does not trigger a change event after deselecting the elements. * * @param Element $element * * @throws DriverException */ private function deselectAllOptions(Element $element): void { $script = <<executeJsOnElement($element, $script); } /** * Ensures the element is of the specified type * * @throws DriverException */ private function ensureInputType(Element $element, string $xpath, string $type, string $action): void { if ('input' !== strtolower($element->name()) || $type !== strtolower($element->attribute('type') ?: '')) { $message = 'Impossible to %s the element with XPath "%s" as it is not a %s input'; throw new DriverException(sprintf($message, $action, $xpath, $type)); } } /** * @throws DriverException */ private function trigger(string $xpath, string $event, string $options = '{}'): void { $script = 'syn.trigger({{ELEMENT}}, "' . $event . '", ' . $options . ')'; $this->withSyn()->executeJsOnXpath($xpath, $script); } /** * Uploads a file to the Selenium instance. * * Note that uploading files is not part of the official WebDriver * specification, but it is supported by Selenium. * * @param string $path The path to the file to upload. * * @return string The remote path. * * @throws DriverException When PHP is compiled without zip support, or the file doesn't exist. * @throws UnknownError When an unknown error occurred during file upload. * @throws \Exception When a known error occurred during file upload. * * @see https://github.com/SeleniumHQ/selenium/blob/master/py/selenium/webdriver/remote/webelement.py#L533 */ private function uploadFile(string $path): string { 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'); if ($tempFilename === false) { throw new DriverException('Could not create a temporary file.'); } $archive = new \ZipArchive(); $result = $archive->open($tempFilename, \ZipArchive::OVERWRITE); if ($result !== true) { 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.'); } $fileContents = file_get_contents($tempFilename); \assert($fileContents !== false); try { $remotePath = $this->getWebDriverSession()->file(array('file' => base64_encode($fileContents))); // If no path is returned the file upload failed silently. In this // case it is possible Selenium was not used but another web driver // such as PhantomJS. // @todo Support other drivers when (if) they get remote file transfer // capability. if (empty($remotePath)) { throw new UnknownError(); } } finally { unlink($tempFilename); } return $remotePath; } }