4 * This file is part of the Behat\Mink.
5 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
11 namespace Behat\Mink\Driver;
13 use Behat\Mink\Exception\DriverException;
14 use Behat\Mink\Selector\Xpath\Escaper;
15 use WebDriver\Element;
16 use WebDriver\Exception\NoSuchElement;
17 use WebDriver\Exception\UnknownError;
18 use WebDriver\Exception;
20 use WebDriver\WebDriver;
25 * @author Pete Otaqui <pete@otaqui.com>
27 class Selenium2Driver extends CoreDriver
30 * Whether the browser has been started
33 private $started = false;
36 * The WebDriver instance
49 private $desiredCapabilities;
52 * The WebDriverSession instance
53 * @var \WebDriver\Session
58 * The timeout configuration
61 private $timeouts = array();
66 private $xpathEscaper;
69 * Instantiates the driver.
71 * @param string $browserName Browser name
72 * @param array $desiredCapabilities The desired capabilities
73 * @param string $wdHost The WebDriver host
75 public function __construct($browserName = 'firefox', $desiredCapabilities = null, $wdHost = 'http://localhost:4444/wd/hub')
77 $this->setBrowserName($browserName);
78 $this->setDesiredCapabilities($desiredCapabilities);
79 $this->setWebDriver(new WebDriver($wdHost));
80 $this->xpathEscaper = new Escaper();
84 * Sets the browser name
86 * @param string $browserName the name of the browser to start, default is 'firefox'
88 protected function setBrowserName($browserName = 'firefox')
90 $this->browserName = $browserName;
94 * Sets the desired capabilities - called on construction. If null is provided, will set the
95 * defaults as desired.
97 * See http://code.google.com/p/selenium/wiki/DesiredCapabilities
99 * @param array $desiredCapabilities an array of capabilities to pass on to the WebDriver server
101 public function setDesiredCapabilities($desiredCapabilities = null)
103 if (null === $desiredCapabilities) {
104 $desiredCapabilities = self::getDefaultCapabilities();
107 if (isset($desiredCapabilities['firefox'])) {
108 foreach ($desiredCapabilities['firefox'] as $capability => $value) {
109 switch ($capability) {
111 $desiredCapabilities['firefox_'.$capability] = base64_encode(file_get_contents($value));
114 $desiredCapabilities['firefox_'.$capability] = $value;
118 unset($desiredCapabilities['firefox']);
121 // See https://sites.google.com/a/chromium.org/chromedriver/capabilities
122 if (isset($desiredCapabilities['chrome'])) {
124 $chromeOptions = array();
126 foreach ($desiredCapabilities['chrome'] as $capability => $value) {
127 if ($capability == 'switches') {
128 $chromeOptions['args'] = $value;
130 $chromeOptions[$capability] = $value;
132 $desiredCapabilities['chrome.'.$capability] = $value;
135 $desiredCapabilities['chromeOptions'] = $chromeOptions;
137 unset($desiredCapabilities['chrome']);
140 $this->desiredCapabilities = $desiredCapabilities;
144 * Sets the WebDriver instance
146 * @param WebDriver $webDriver An instance of the WebDriver class
148 public function setWebDriver(WebDriver $webDriver)
150 $this->webDriver = $webDriver;
154 * Gets the WebDriverSession instance
156 * @return \WebDriver\Session
158 public function getWebDriverSession()
160 return $this->wdSession;
164 * Returns the default capabilities
168 public static function getDefaultCapabilities()
171 'browserName' => 'firefox',
174 'browserVersion' => '9',
175 'browser' => 'firefox',
176 'name' => 'Behat Test',
177 'deviceOrientation' => 'portrait',
178 'deviceType' => 'tablet',
179 'selenium-version' => '2.31.0'
184 * Makes sure that the Syn event library has been injected into the current page,
185 * and return $this for a fluid interface,
187 * $this->withSyn()->executeJsOnXpath($xpath, $script);
189 * @return Selenium2Driver
191 protected function withSyn()
193 $hasSyn = $this->wdSession->execute(array(
194 'script' => 'return typeof window["Syn"]!=="undefined" && typeof window["Syn"].trigger!=="undefined"',
199 $synJs = file_get_contents(__DIR__.'/Resources/syn.js');
200 $this->wdSession->execute(array(
210 * Creates some options for key events
212 * @param string $char the character or code
213 * @param string $modifier one of 'shift', 'alt', 'ctrl' or 'meta'
215 * @return string a json encoded options array for Syn
217 protected static function charToOptions($char, $modifier = null)
220 if (is_numeric($char)) {
230 $options[$modifier.'Key'] = 1;
233 return json_encode($options);
237 * Executes JS on a given element - pass in a js script string and {{ELEMENT}} will
238 * be replaced with a reference to the result of the $xpath query
240 * @example $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.childNodes.length');
242 * @param string $xpath the xpath to search with
243 * @param string $script the script to execute
244 * @param Boolean $sync whether to run the script synchronously (default is TRUE)
248 protected function executeJsOnXpath($xpath, $script, $sync = true)
250 return $this->executeJsOnElement($this->findElement($xpath), $script, $sync);
254 * Executes JS on a given element - pass in a js script string and {{ELEMENT}} will
255 * be replaced with a reference to the element
257 * @example $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.childNodes.length');
259 * @param Element $element the webdriver element
260 * @param string $script the script to execute
261 * @param Boolean $sync whether to run the script synchronously (default is TRUE)
265 private function executeJsOnElement(Element $element, $script, $sync = true)
267 $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script);
271 'args' => array(array('ELEMENT' => $element->getID())),
275 return $this->wdSession->execute($options);
278 return $this->wdSession->execute_async($options);
284 public function start()
287 $this->wdSession = $this->webDriver->session($this->browserName, $this->desiredCapabilities);
288 $this->applyTimeouts();
289 } catch (\Exception $e) {
290 throw new DriverException('Could not open connection: '.$e->getMessage(), 0, $e);
293 if (!$this->wdSession) {
294 throw new DriverException('Could not connect to a Selenium 2 / WebDriver server');
296 $this->started = true;
300 * Sets the timeouts to apply to the webdriver session
302 * @param array $timeouts The session timeout settings: Array of {script, implicit, page} => time in milliseconds
304 * @throws DriverException
306 public function setTimeouts($timeouts)
308 $this->timeouts = $timeouts;
310 if ($this->isStarted()) {
311 $this->applyTimeouts();
316 * Applies timeouts to the current session
318 private function applyTimeouts()
321 foreach ($this->timeouts as $type => $param) {
322 $this->wdSession->timeouts($type, $param);
324 } catch (UnknownError $e) {
325 throw new DriverException('Error setting timeout: ' . $e->getMessage(), 0, $e);
332 public function isStarted()
334 return $this->started;
340 public function stop()
342 if (!$this->wdSession) {
343 throw new DriverException('Could not connect to a Selenium 2 / WebDriver server');
346 $this->started = false;
348 $this->wdSession->close();
349 } catch (\Exception $e) {
350 throw new DriverException('Could not close connection', 0, $e);
357 public function reset()
359 $this->wdSession->deleteAllCookies();
365 public function visit($url)
367 $this->wdSession->open($url);
373 public function getCurrentUrl()
375 return $this->wdSession->url();
381 public function reload()
383 $this->wdSession->refresh();
389 public function forward()
391 $this->wdSession->forward();
397 public function back()
399 $this->wdSession->back();
405 public function switchToWindow($name = null)
407 $this->wdSession->focusWindow($name ? $name : '');
413 public function switchToIFrame($name = null)
415 $this->wdSession->frame(array('id' => $name));
421 public function setCookie($name, $value = null)
423 if (null === $value) {
424 $this->wdSession->deleteCookie($name);
429 $cookieArray = array(
431 'value' => urlencode($value),
432 'secure' => false, // thanks, chibimagic!
435 $this->wdSession->setCookie($cookieArray);
441 public function getCookie($name)
443 $cookies = $this->wdSession->getAllCookies();
444 foreach ($cookies as $cookie) {
445 if ($cookie['name'] === $name) {
446 return urldecode($cookie['value']);
454 public function getContent()
456 return $this->wdSession->source();
462 public function getScreenshot()
464 return base64_decode($this->wdSession->screenshot());
470 public function getWindowNames()
472 return $this->wdSession->window_handles();
478 public function getWindowName()
480 return $this->wdSession->window_handle();
486 public function findElementXpaths($xpath)
488 $nodes = $this->wdSession->elements('xpath', $xpath);
491 foreach ($nodes as $i => $node) {
492 $elements[] = sprintf('(%s)[%d]', $xpath, $i+1);
501 public function getTagName($xpath)
503 return $this->findElement($xpath)->name();
509 public function getText($xpath)
511 $node = $this->findElement($xpath);
512 $text = $node->text();
513 $text = (string) str_replace(array("\r", "\r\n", "\n"), ' ', $text);
521 public function getHtml($xpath)
523 return $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.innerHTML;');
529 public function getOuterHtml($xpath)
531 return $this->executeJsOnXpath($xpath, 'return {{ELEMENT}}.outerHTML;');
537 public function getAttribute($xpath, $name)
539 $script = 'return {{ELEMENT}}.getAttribute(' . json_encode((string) $name) . ')';
541 return $this->executeJsOnXpath($xpath, $script);
547 public function getValue($xpath)
549 $element = $this->findElement($xpath);
550 $elementName = strtolower($element->name());
551 $elementType = strtolower($element->attribute('type'));
553 // Getting the value of a checkbox returns its value if selected.
554 if ('input' === $elementName && 'checkbox' === $elementType) {
555 return $element->selected() ? $element->attribute('value') : null;
558 if ('input' === $elementName && 'radio' === $elementType) {
560 var node = {{ELEMENT}},
563 var name = node.getAttribute('name');
565 var fields = window.document.getElementsByName(name),
566 i, l = fields.length;
567 for (i = 0; i < l; i++) {
568 var field = fields.item(i);
569 if (field.form === node.form && field.checked) {
579 return $this->executeJsOnElement($element, $script);
582 // Using $element->attribute('value') on a select only returns the first selected option
583 // even when it is a multiple select, so a custom retrieval is needed.
584 if ('select' === $elementName && $element->attribute('multiple')) {
586 var node = {{ELEMENT}},
589 for (var i = 0; i < node.options.length; i++) {
590 if (node.options[i].selected) {
591 value.push(node.options[i].value);
598 return $this->executeJsOnElement($element, $script);
601 return $element->attribute('value');
607 public function setValue($xpath, $value)
609 $element = $this->findElement($xpath);
610 $elementName = strtolower($element->name());
612 if ('select' === $elementName) {
613 if (is_array($value)) {
614 $this->deselectAllOptions($element);
616 foreach ($value as $option) {
617 $this->selectOptionOnElement($element, $option, true);
623 $this->selectOptionOnElement($element, $value);
628 if ('input' === $elementName) {
629 $elementType = strtolower($element->attribute('type'));
631 if (in_array($elementType, array('submit', 'image', 'button', 'reset'))) {
632 throw new DriverException(sprintf('Impossible to set value an element with XPath "%s" as it is not a select, textarea or textbox', $xpath));
635 if ('checkbox' === $elementType) {
636 if ($element->selected() xor (bool) $value) {
637 $this->clickOnElement($element);
643 if ('radio' === $elementType) {
644 $this->selectRadioValue($element, $value);
649 if ('file' === $elementType) {
650 $element->postValue(array('value' => array(strval($value))));
656 $value = strval($value);
658 if (in_array($elementName, array('input', 'textarea'))) {
659 $existingValueLength = strlen($element->attribute('value'));
660 // Add the TAB key to ensure we unfocus the field as browsers are triggering the change event only
661 // after leaving the field.
662 $value = str_repeat(Key::BACKSPACE . Key::DELETE, $existingValueLength) . $value . Key::TAB;
665 $element->postValue(array('value' => array($value)));
671 public function check($xpath)
673 $element = $this->findElement($xpath);
674 $this->ensureInputType($element, $xpath, 'checkbox', 'check');
676 if ($element->selected()) {
680 $this->clickOnElement($element);
686 public function uncheck($xpath)
688 $element = $this->findElement($xpath);
689 $this->ensureInputType($element, $xpath, 'checkbox', 'uncheck');
691 if (!$element->selected()) {
695 $this->clickOnElement($element);
701 public function isChecked($xpath)
703 return $this->findElement($xpath)->selected();
709 public function selectOption($xpath, $value, $multiple = false)
711 $element = $this->findElement($xpath);
712 $tagName = strtolower($element->name());
714 if ('input' === $tagName && 'radio' === strtolower($element->attribute('type'))) {
715 $this->selectRadioValue($element, $value);
720 if ('select' === $tagName) {
721 $this->selectOptionOnElement($element, $value, $multiple);
726 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));
732 public function isSelected($xpath)
734 return $this->findElement($xpath)->selected();
740 public function click($xpath)
742 $this->clickOnElement($this->findElement($xpath));
745 private function clickOnElement(Element $element)
747 $this->wdSession->moveto(array('element' => $element->getID()));
754 public function doubleClick($xpath)
756 $this->mouseOver($xpath);
757 $this->wdSession->doubleclick();
763 public function rightClick($xpath)
765 $this->mouseOver($xpath);
766 $this->wdSession->click(array('button' => 2));
772 public function attachFile($xpath, $path)
774 $element = $this->findElement($xpath);
775 $this->ensureInputType($element, $xpath, 'file', 'attach a file on');
777 $element->postValue(array('value' => array($path)));
783 public function isVisible($xpath)
785 return $this->findElement($xpath)->displayed();
791 public function mouseOver($xpath)
793 $this->wdSession->moveto(array(
794 'element' => $this->findElement($xpath)->getID()
801 public function focus($xpath)
803 $script = 'Syn.trigger("focus", {}, {{ELEMENT}})';
804 $this->withSyn()->executeJsOnXpath($xpath, $script);
810 public function blur($xpath)
812 $script = 'Syn.trigger("blur", {}, {{ELEMENT}})';
813 $this->withSyn()->executeJsOnXpath($xpath, $script);
819 public function keyPress($xpath, $char, $modifier = null)
821 $options = self::charToOptions($char, $modifier);
822 $script = "Syn.trigger('keypress', $options, {{ELEMENT}})";
823 $this->withSyn()->executeJsOnXpath($xpath, $script);
829 public function keyDown($xpath, $char, $modifier = null)
831 $options = self::charToOptions($char, $modifier);
832 $script = "Syn.trigger('keydown', $options, {{ELEMENT}})";
833 $this->withSyn()->executeJsOnXpath($xpath, $script);
839 public function keyUp($xpath, $char, $modifier = null)
841 $options = self::charToOptions($char, $modifier);
842 $script = "Syn.trigger('keyup', $options, {{ELEMENT}})";
843 $this->withSyn()->executeJsOnXpath($xpath, $script);
849 public function dragTo($sourceXpath, $destinationXpath)
851 $source = $this->findElement($sourceXpath);
852 $destination = $this->findElement($destinationXpath);
854 $this->wdSession->moveto(array(
855 'element' => $source->getID()
859 (function (element) {
860 var event = document.createEvent("HTMLEvents");
862 event.initEvent("dragstart", true, true);
863 event.dataTransfer = {};
865 element.dispatchEvent(event);
868 $this->withSyn()->executeJsOnElement($source, $script);
870 $this->wdSession->buttondown();
871 $this->wdSession->moveto(array(
872 'element' => $destination->getID()
874 $this->wdSession->buttonup();
877 (function (element) {
878 var event = document.createEvent("HTMLEvents");
880 event.initEvent("drop", true, true);
881 event.dataTransfer = {};
883 element.dispatchEvent(event);
886 $this->withSyn()->executeJsOnElement($destination, $script);
892 public function executeScript($script)
894 if (preg_match('/^function[\s\(]/', $script)) {
895 $script = preg_replace('/;$/', '', $script);
896 $script = '(' . $script . ')';
899 $this->wdSession->execute(array('script' => $script, 'args' => array()));
905 public function evaluateScript($script)
907 if (0 !== strpos(trim($script), 'return ')) {
908 $script = 'return ' . $script;
911 return $this->wdSession->execute(array('script' => $script, 'args' => array()));
917 public function wait($timeout, $condition)
919 $script = "return $condition;";
920 $start = microtime(true);
921 $end = $start + $timeout / 1000.0;
924 $result = $this->wdSession->execute(array('script' => $script, 'args' => array()));
926 } while (microtime(true) < $end && !$result);
928 return (bool) $result;
934 public function resizeWindow($width, $height, $name = null)
936 $this->wdSession->window($name ? $name : 'current')->postSize(
937 array('width' => $width, 'height' => $height)
944 public function submitForm($xpath)
946 $this->findElement($xpath)->submit();
952 public function maximizeWindow($name = null)
954 $this->wdSession->window($name ? $name : 'current')->maximize();
958 * Returns Session ID of WebDriver or `null`, when session not started yet.
960 * @return string|null
962 public function getWebDriverSessionId()
964 return $this->isStarted() ? basename($this->wdSession->getUrl()) : null;
968 * @param string $xpath
972 private function findElement($xpath)
974 return $this->wdSession->element('xpath', $xpath);
978 * Selects a value in a radio button group
980 * @param Element $element An element referencing one of the radio buttons of the group
981 * @param string $value The value to select
983 * @throws DriverException when the value cannot be found
985 private function selectRadioValue(Element $element, $value)
987 // short-circuit when we already have the right button of the group to avoid XPath queries
988 if ($element->attribute('value') === $value) {
994 $name = $element->attribute('name');
997 throw new DriverException(sprintf('The radio button does not have the value "%s"', $value));
1000 $formId = $element->attribute('form');
1003 if (null !== $formId) {
1005 //form[@id=%1$s]//input[@type="radio" and not(@form) and @name=%2$s and @value = %3$s]
1007 //input[@type="radio" and @form=%1$s and @name=%2$s and @value = %3$s]
1012 $this->xpathEscaper->escapeLiteral($formId),
1013 $this->xpathEscaper->escapeLiteral($name),
1014 $this->xpathEscaper->escapeLiteral($value)
1016 $input = $this->wdSession->element('xpath', $xpath);
1019 './ancestor::form//input[@type="radio" and not(@form) and @name=%s and @value = %s]',
1020 $this->xpathEscaper->escapeLiteral($name),
1021 $this->xpathEscaper->escapeLiteral($value)
1023 $input = $element->element('xpath', $xpath);
1025 } catch (NoSuchElement $e) {
1026 $message = sprintf('The radio group "%s" does not have an option "%s"', $name, $value);
1028 throw new DriverException($message, 0, $e);
1035 * @param Element $element
1036 * @param string $value
1037 * @param bool $multiple
1039 private function selectOptionOnElement(Element $element, $value, $multiple = false)
1041 $escapedValue = $this->xpathEscaper->escapeLiteral($value);
1042 // The value of an option is the normalized version of its text when it has no value attribute
1043 $optionQuery = sprintf('.//option[@value = %s or (not(@value) and normalize-space(.) = %s)]', $escapedValue, $escapedValue);
1044 $option = $element->element('xpath', $optionQuery);
1046 if ($multiple || !$element->attribute('multiple')) {
1047 if (!$option->selected()) {
1054 // Deselect all options before selecting the new one
1055 $this->deselectAllOptions($element);
1060 * Deselects all options of a multiple select
1062 * Note: this implementation does not trigger a change event after deselecting the elements.
1064 * @param Element $element
1066 private function deselectAllOptions(Element $element)
1069 var node = {{ELEMENT}};
1070 var i, l = node.options.length;
1071 for (i = 0; i < l; i++) {
1072 node.options[i].selected = false;
1076 $this->executeJsOnElement($element, $script);
1080 * Ensures the element is a checkbox
1082 * @param Element $element
1083 * @param string $xpath
1084 * @param string $type
1085 * @param string $action
1087 * @throws DriverException
1089 private function ensureInputType(Element $element, $xpath, $type, $action)
1091 if ('input' !== strtolower($element->name()) || $type !== strtolower($element->attribute('type'))) {
1092 $message = 'Impossible to %s the element with XPath "%s" as it is not a %s input';
1094 throw new DriverException(sprintf($message, $action, $xpath, $type));