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\Exception\UnsupportedDriverActionException;
15 use Symfony\Component\BrowserKit\Client;
16 use Symfony\Component\BrowserKit\Cookie;
17 use Symfony\Component\BrowserKit\Response;
18 use Symfony\Component\DomCrawler\Crawler;
19 use Symfony\Component\DomCrawler\Field\ChoiceFormField;
20 use Symfony\Component\DomCrawler\Field\FileFormField;
21 use Symfony\Component\DomCrawler\Field\FormField;
22 use Symfony\Component\DomCrawler\Field\InputFormField;
23 use Symfony\Component\DomCrawler\Field\TextareaFormField;
24 use Symfony\Component\DomCrawler\Form;
25 use Symfony\Component\HttpKernel\Client as HttpKernelClient;
28 * Symfony2 BrowserKit driver.
30 * @author Konstantin Kudryashov <ever.zet@gmail.com>
32 class BrowserKitDriver extends CoreDriver
39 private $forms = array();
40 private $serverParameters = array();
41 private $started = false;
42 private $removeScriptFromUrl = false;
43 private $removeHostFromUrl = false;
46 * Initializes BrowserKit driver.
48 * @param Client $client BrowserKit client instance
49 * @param string|null $baseUrl Base URL for HttpKernel clients
51 public function __construct(Client $client, $baseUrl = null)
53 $this->client = $client;
54 $this->client->followRedirects(true);
56 if ($baseUrl !== null && $client instanceof HttpKernelClient) {
57 $client->setServerParameter('SCRIPT_FILENAME', parse_url($baseUrl, PHP_URL_PATH));
62 * Returns BrowserKit HTTP client instance.
66 public function getClient()
72 * Tells driver to remove hostname from URL.
74 * @param Boolean $remove
76 * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
78 public function setRemoveHostFromUrl($remove = true)
81 'setRemoveHostFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
84 $this->removeHostFromUrl = (bool) $remove;
88 * Tells driver to remove script name from URL.
90 * @param Boolean $remove
92 * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
94 public function setRemoveScriptFromUrl($remove = true)
97 'setRemoveScriptFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
100 $this->removeScriptFromUrl = (bool) $remove;
106 public function start()
108 $this->started = true;
114 public function isStarted()
116 return $this->started;
122 public function stop()
125 $this->started = false;
131 public function reset()
133 // Restarting the client resets the cookies and the history
134 $this->client->restart();
135 $this->forms = array();
136 $this->serverParameters = array();
142 public function visit($url)
144 $this->client->request('GET', $this->prepareUrl($url), array(), array(), $this->serverParameters);
145 $this->forms = array();
151 public function getCurrentUrl()
153 $request = $this->client->getInternalRequest();
155 if ($request === null) {
156 throw new DriverException('Unable to access the request before visiting a page');
159 return $request->getUri();
165 public function reload()
167 $this->client->reload();
168 $this->forms = array();
174 public function forward()
176 $this->client->forward();
177 $this->forms = array();
183 public function back()
185 $this->client->back();
186 $this->forms = array();
192 public function setBasicAuth($user, $password)
194 if (false === $user) {
195 unset($this->serverParameters['PHP_AUTH_USER'], $this->serverParameters['PHP_AUTH_PW']);
200 $this->serverParameters['PHP_AUTH_USER'] = $user;
201 $this->serverParameters['PHP_AUTH_PW'] = $password;
207 public function setRequestHeader($name, $value)
209 $contentHeaders = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true);
210 $name = str_replace('-', '_', strtoupper($name));
212 // CONTENT_* are not prefixed with HTTP_ in PHP when building $_SERVER
213 if (!isset($contentHeaders[$name])) {
214 $name = 'HTTP_' . $name;
217 $this->serverParameters[$name] = $value;
223 public function getResponseHeaders()
225 return $this->getResponse()->getHeaders();
231 public function setCookie($name, $value = null)
233 if (null === $value) {
234 $this->deleteCookie($name);
239 $jar = $this->client->getCookieJar();
240 $jar->set(new Cookie($name, $value));
244 * Deletes a cookie by name.
246 * @param string $name Cookie name.
248 private function deleteCookie($name)
250 $path = $this->getCookiePath();
251 $jar = $this->client->getCookieJar();
254 if (null !== $jar->get($name, $path)) {
255 $jar->expire($name, $path);
258 $path = preg_replace('/.$/', '', $path);
263 * Returns current cookie path.
267 private function getCookiePath()
269 $path = dirname(parse_url($this->getCurrentUrl(), PHP_URL_PATH));
271 if ('\\' === DIRECTORY_SEPARATOR) {
272 $path = str_replace('\\', '/', $path);
281 public function getCookie($name)
283 // Note that the following doesn't work well because
284 // Symfony\Component\BrowserKit\CookieJar stores cookies by name,
285 // path, AND domain and if you don't fill them all in correctly then
286 // you won't get the value that you're expecting.
288 // $jar = $this->client->getCookieJar();
290 // if (null !== $cookie = $jar->get($name)) {
291 // return $cookie->getValue();
294 $allValues = $this->client->getCookieJar()->allValues($this->getCurrentUrl());
296 if (isset($allValues[$name])) {
297 return $allValues[$name];
306 public function getStatusCode()
308 return $this->getResponse()->getStatus();
314 public function getContent()
316 return $this->getResponse()->getContent();
322 public function findElementXpaths($xpath)
324 $nodes = $this->getCrawler()->filterXPath($xpath);
327 foreach ($nodes as $i => $node) {
328 $elements[] = sprintf('(%s)[%d]', $xpath, $i + 1);
337 public function getTagName($xpath)
339 return $this->getCrawlerNode($this->getFilteredCrawler($xpath))->nodeName;
345 public function getText($xpath)
347 $text = $this->getFilteredCrawler($xpath)->text();
348 $text = str_replace("\n", ' ', $text);
349 $text = preg_replace('/ {2,}/', ' ', $text);
357 public function getHtml($xpath)
359 // cut the tag itself (making innerHTML out of outerHTML)
360 return preg_replace('/^\<[^\>]+\>|\<[^\>]+\>$/', '', $this->getOuterHtml($xpath));
366 public function getOuterHtml($xpath)
368 $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
370 return $node->ownerDocument->saveHTML($node);
376 public function getAttribute($xpath, $name)
378 $node = $this->getFilteredCrawler($xpath);
380 if ($this->getCrawlerNode($node)->hasAttribute($name)) {
381 return $node->attr($name);
390 public function getValue($xpath)
392 if (in_array($this->getAttribute($xpath, 'type'), array('submit', 'image', 'button'), true)) {
393 return $this->getAttribute($xpath, 'value');
396 $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
398 if ('option' === $node->tagName) {
399 return $this->getOptionValue($node);
403 $field = $this->getFormField($xpath);
404 } catch (\InvalidArgumentException $e) {
405 return $this->getAttribute($xpath, 'value');
408 return $field->getValue();
414 public function setValue($xpath, $value)
416 $this->getFormField($xpath)->setValue($value);
422 public function check($xpath)
424 $this->getCheckboxField($xpath)->tick();
430 public function uncheck($xpath)
432 $this->getCheckboxField($xpath)->untick();
438 public function selectOption($xpath, $value, $multiple = false)
440 $field = $this->getFormField($xpath);
442 if (!$field instanceof ChoiceFormField) {
443 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));
447 $oldValue = (array) $field->getValue();
448 $oldValue[] = $value;
452 $field->select($value);
458 public function isSelected($xpath)
460 $optionValue = $this->getOptionValue($this->getCrawlerNode($this->getFilteredCrawler($xpath)));
461 $selectField = $this->getFormField('(' . $xpath . ')/ancestor-or-self::*[local-name()="select"]');
462 $selectValue = $selectField->getValue();
464 return is_array($selectValue) ? in_array($optionValue, $selectValue, true) : $optionValue === $selectValue;
470 public function click($xpath)
472 $crawler = $this->getFilteredCrawler($xpath);
473 $node = $this->getCrawlerNode($crawler);
474 $tagName = $node->nodeName;
476 if ('a' === $tagName) {
477 $this->client->click($crawler->link());
478 $this->forms = array();
479 } elseif ($this->canSubmitForm($node)) {
480 $this->submit($crawler->form());
481 } elseif ($this->canResetForm($node)) {
482 $this->resetForm($node);
484 $message = sprintf('%%s supports clicking on links and submit or reset buttons only. But "%s" provided', $tagName);
486 throw new UnsupportedDriverActionException($message, $this);
493 public function isChecked($xpath)
495 $field = $this->getFormField($xpath);
497 if (!$field instanceof ChoiceFormField || 'select' === $field->getType()) {
498 throw new DriverException(sprintf('Impossible to get the checked state of the element with XPath "%s" as it is not a checkbox or radio input', $xpath));
501 if ('checkbox' === $field->getType()) {
502 return $field->hasValue();
505 $radio = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
507 return $radio->getAttribute('value') === $field->getValue();
513 public function attachFile($xpath, $path)
515 $field = $this->getFormField($xpath);
517 if (!$field instanceof FileFormField) {
518 throw new DriverException(sprintf('Impossible to attach a file on the element with XPath "%s" as it is not a file input', $xpath));
521 $field->upload($path);
527 public function submitForm($xpath)
529 $crawler = $this->getFilteredCrawler($xpath);
531 $this->submit($crawler->form());
537 * @throws DriverException If there is not response yet
539 protected function getResponse()
541 $response = $this->client->getInternalResponse();
543 if (null === $response) {
544 throw new DriverException('Unable to access the response before visiting a page');
551 * Prepares URL for visiting.
552 * Removes "*.php/" from urls and then passes it to BrowserKitDriver::visit().
558 protected function prepareUrl($url)
560 $replacement = ($this->removeHostFromUrl ? '' : '$1') . ($this->removeScriptFromUrl ? '' : '$2');
562 return preg_replace('#(https?\://[^/]+)(/[^/\.]+\.php)?#', $replacement, $url);
566 * Returns form field from XPath query.
568 * @param string $xpath
572 * @throws DriverException
574 protected function getFormField($xpath)
576 $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
577 $fieldName = str_replace('[]', '', $fieldNode->getAttribute('name'));
579 $formNode = $this->getFormNode($fieldNode);
580 $formId = $this->getFormNodeId($formNode);
582 if (!isset($this->forms[$formId])) {
583 $this->forms[$formId] = new Form($formNode, $this->getCurrentUrl());
586 if (is_array($this->forms[$formId][$fieldName])) {
587 return $this->forms[$formId][$fieldName][$this->getFieldPosition($fieldNode)];
590 return $this->forms[$formId][$fieldName];
594 * Returns the checkbox field from xpath query, ensuring it is valid.
596 * @param string $xpath
598 * @return ChoiceFormField
600 * @throws DriverException when the field is not a checkbox
602 private function getCheckboxField($xpath)
604 $field = $this->getFormField($xpath);
606 if (!$field instanceof ChoiceFormField) {
607 throw new DriverException(sprintf('Impossible to check the element with XPath "%s" as it is not a checkbox', $xpath));
614 * @param \DOMElement $element
616 * @return \DOMElement
618 * @throws DriverException if the form node cannot be found
620 private function getFormNode(\DOMElement $element)
622 if ($element->hasAttribute('form')) {
623 $formId = $element->getAttribute('form');
624 $formNode = $element->ownerDocument->getElementById($formId);
626 if (null === $formNode || 'form' !== $formNode->nodeName) {
627 throw new DriverException(sprintf('The selected node has an invalid form attribute (%s).', $formId));
633 $formNode = $element;
636 // use the ancestor form element
637 if (null === $formNode = $formNode->parentNode) {
638 throw new DriverException('The selected node does not have a form ancestor.');
640 } while ('form' !== $formNode->nodeName);
646 * Gets the position of the field node among elements with the same name
648 * BrowserKit uses the field name as index to find the field in its Form object.
649 * When multiple fields have the same name (checkboxes for instance), it will return
650 * an array of elements in the order they appear in the DOM.
652 * @param \DOMElement $fieldNode
656 private function getFieldPosition(\DOMElement $fieldNode)
658 $elements = $this->getCrawler()->filterXPath('//*[@name=\''.$fieldNode->getAttribute('name').'\']');
660 if (count($elements) > 1) {
661 // more than one element contains this name !
662 // so we need to find the position of $fieldNode
663 foreach ($elements as $key => $element) {
664 /** @var \DOMElement $element */
665 if ($element->getNodePath() === $fieldNode->getNodePath()) {
674 private function submit(Form $form)
676 $formId = $this->getFormNodeId($form->getFormNode());
678 if (isset($this->forms[$formId])) {
679 $this->mergeForms($form, $this->forms[$formId]);
682 // remove empty file fields from request
683 foreach ($form->getFiles() as $name => $field) {
684 if (empty($field['name']) && empty($field['tmp_name'])) {
685 $form->remove($name);
689 foreach ($form->all() as $field) {
690 // Add a fix for https://github.com/symfony/symfony/pull/10733 to support Symfony versions which are not fixed
691 if ($field instanceof TextareaFormField && null === $field->getValue()) {
692 $field->setValue('');
696 $this->client->submit($form);
698 $this->forms = array();
701 private function resetForm(\DOMElement $fieldNode)
703 $formNode = $this->getFormNode($fieldNode);
704 $formId = $this->getFormNodeId($formNode);
705 unset($this->forms[$formId]);
709 * Determines if a node can submit a form.
711 * @param \DOMElement $node Node.
715 private function canSubmitForm(\DOMElement $node)
717 $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
719 if ('input' === $node->nodeName && in_array($type, array('submit', 'image'), true)) {
723 return 'button' === $node->nodeName && (null === $type || 'submit' === $type);
727 * Determines if a node can reset a form.
729 * @param \DOMElement $node Node.
733 private function canResetForm(\DOMElement $node)
735 $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
737 return in_array($node->nodeName, array('input', 'button'), true) && 'reset' === $type;
741 * Returns form node unique identifier.
743 * @param \DOMElement $form
747 private function getFormNodeId(\DOMElement $form)
749 return md5($form->getLineNo() . $form->getNodePath() . $form->nodeValue);
753 * Gets the value of an option element
755 * @param \DOMElement $option
759 * @see \Symfony\Component\DomCrawler\Field\ChoiceFormField::buildOptionValue
761 private function getOptionValue(\DOMElement $option)
763 if ($option->hasAttribute('value')) {
764 return $option->getAttribute('value');
767 if (!empty($option->nodeValue)) {
768 return $option->nodeValue;
771 return '1'; // DomCrawler uses 1 by default if there is no text in the option
775 * Merges second form values into first one.
777 * @param Form $to merging target
778 * @param Form $from merging source
780 private function mergeForms(Form $to, Form $from)
782 foreach ($from->all() as $name => $field) {
783 $fieldReflection = new \ReflectionObject($field);
784 $nodeReflection = $fieldReflection->getProperty('node');
785 $valueReflection = $fieldReflection->getProperty('value');
787 $nodeReflection->setAccessible(true);
788 $valueReflection->setAccessible(true);
790 $isIgnoredField = $field instanceof InputFormField &&
791 in_array($nodeReflection->getValue($field)->getAttribute('type'), array('submit', 'button', 'image'), true);
793 if (!$isIgnoredField) {
794 $valueReflection->setValue($to[$name], $valueReflection->getValue($field));
800 * Returns DOMElement from crawler instance.
802 * @param Crawler $crawler
804 * @return \DOMElement
806 * @throws DriverException when the node does not exist
808 private function getCrawlerNode(Crawler $crawler)
812 if ($crawler instanceof \Iterator) {
813 // for symfony 2.3 compatibility as getNode is not public before symfony 2.4
815 $node = $crawler->current();
817 $node = $crawler->getNode(0);
820 if (null !== $node) {
824 throw new DriverException('The element does not exist');
828 * Returns a crawler filtered for the given XPath, requiring at least 1 result.
830 * @param string $xpath
834 * @throws DriverException when no matching elements are found
836 private function getFilteredCrawler($xpath)
838 if (!count($crawler = $this->getCrawler()->filterXPath($xpath))) {
839 throw new DriverException(sprintf('There is no element matching XPath "%s"', $xpath));
846 * Returns crawler instance (got from client).
850 * @throws DriverException
852 private function getCrawler()
854 $crawler = $this->client->getCrawler();
856 if (null === $crawler) {
857 throw new DriverException('Unable to access the response content before visiting a page');