comparison vendor/behat/mink-browserkit-driver/src/BrowserKitDriver.php @ 0:4c8ae668cc8c

Initial import (non-working)
author Chris Cannam
date Wed, 29 Nov 2017 16:09:58 +0000
parents
children c2387f117808
comparison
equal deleted inserted replaced
-1:000000000000 0:4c8ae668cc8c
1 <?php
2
3 /*
4 * This file is part of the Behat\Mink.
5 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10
11 namespace Behat\Mink\Driver;
12
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;
26
27 /**
28 * Symfony2 BrowserKit driver.
29 *
30 * @author Konstantin Kudryashov <ever.zet@gmail.com>
31 */
32 class BrowserKitDriver extends CoreDriver
33 {
34 private $client;
35
36 /**
37 * @var Form[]
38 */
39 private $forms = array();
40 private $serverParameters = array();
41 private $started = false;
42 private $removeScriptFromUrl = false;
43 private $removeHostFromUrl = false;
44
45 /**
46 * Initializes BrowserKit driver.
47 *
48 * @param Client $client BrowserKit client instance
49 * @param string|null $baseUrl Base URL for HttpKernel clients
50 */
51 public function __construct(Client $client, $baseUrl = null)
52 {
53 $this->client = $client;
54 $this->client->followRedirects(true);
55
56 if ($baseUrl !== null && $client instanceof HttpKernelClient) {
57 $client->setServerParameter('SCRIPT_FILENAME', parse_url($baseUrl, PHP_URL_PATH));
58 }
59 }
60
61 /**
62 * Returns BrowserKit HTTP client instance.
63 *
64 * @return Client
65 */
66 public function getClient()
67 {
68 return $this->client;
69 }
70
71 /**
72 * Tells driver to remove hostname from URL.
73 *
74 * @param Boolean $remove
75 *
76 * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
77 */
78 public function setRemoveHostFromUrl($remove = true)
79 {
80 trigger_error(
81 'setRemoveHostFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
82 E_USER_DEPRECATED
83 );
84 $this->removeHostFromUrl = (bool) $remove;
85 }
86
87 /**
88 * Tells driver to remove script name from URL.
89 *
90 * @param Boolean $remove
91 *
92 * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
93 */
94 public function setRemoveScriptFromUrl($remove = true)
95 {
96 trigger_error(
97 'setRemoveScriptFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
98 E_USER_DEPRECATED
99 );
100 $this->removeScriptFromUrl = (bool) $remove;
101 }
102
103 /**
104 * {@inheritdoc}
105 */
106 public function start()
107 {
108 $this->started = true;
109 }
110
111 /**
112 * {@inheritdoc}
113 */
114 public function isStarted()
115 {
116 return $this->started;
117 }
118
119 /**
120 * {@inheritdoc}
121 */
122 public function stop()
123 {
124 $this->reset();
125 $this->started = false;
126 }
127
128 /**
129 * {@inheritdoc}
130 */
131 public function reset()
132 {
133 // Restarting the client resets the cookies and the history
134 $this->client->restart();
135 $this->forms = array();
136 $this->serverParameters = array();
137 }
138
139 /**
140 * {@inheritdoc}
141 */
142 public function visit($url)
143 {
144 $this->client->request('GET', $this->prepareUrl($url), array(), array(), $this->serverParameters);
145 $this->forms = array();
146 }
147
148 /**
149 * {@inheritdoc}
150 */
151 public function getCurrentUrl()
152 {
153 $request = $this->client->getInternalRequest();
154
155 if ($request === null) {
156 throw new DriverException('Unable to access the request before visiting a page');
157 }
158
159 return $request->getUri();
160 }
161
162 /**
163 * {@inheritdoc}
164 */
165 public function reload()
166 {
167 $this->client->reload();
168 $this->forms = array();
169 }
170
171 /**
172 * {@inheritdoc}
173 */
174 public function forward()
175 {
176 $this->client->forward();
177 $this->forms = array();
178 }
179
180 /**
181 * {@inheritdoc}
182 */
183 public function back()
184 {
185 $this->client->back();
186 $this->forms = array();
187 }
188
189 /**
190 * {@inheritdoc}
191 */
192 public function setBasicAuth($user, $password)
193 {
194 if (false === $user) {
195 unset($this->serverParameters['PHP_AUTH_USER'], $this->serverParameters['PHP_AUTH_PW']);
196
197 return;
198 }
199
200 $this->serverParameters['PHP_AUTH_USER'] = $user;
201 $this->serverParameters['PHP_AUTH_PW'] = $password;
202 }
203
204 /**
205 * {@inheritdoc}
206 */
207 public function setRequestHeader($name, $value)
208 {
209 $contentHeaders = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true);
210 $name = str_replace('-', '_', strtoupper($name));
211
212 // CONTENT_* are not prefixed with HTTP_ in PHP when building $_SERVER
213 if (!isset($contentHeaders[$name])) {
214 $name = 'HTTP_' . $name;
215 }
216
217 $this->serverParameters[$name] = $value;
218 }
219
220 /**
221 * {@inheritdoc}
222 */
223 public function getResponseHeaders()
224 {
225 return $this->getResponse()->getHeaders();
226 }
227
228 /**
229 * {@inheritdoc}
230 */
231 public function setCookie($name, $value = null)
232 {
233 if (null === $value) {
234 $this->deleteCookie($name);
235
236 return;
237 }
238
239 $jar = $this->client->getCookieJar();
240 $jar->set(new Cookie($name, $value));
241 }
242
243 /**
244 * Deletes a cookie by name.
245 *
246 * @param string $name Cookie name.
247 */
248 private function deleteCookie($name)
249 {
250 $path = $this->getCookiePath();
251 $jar = $this->client->getCookieJar();
252
253 do {
254 if (null !== $jar->get($name, $path)) {
255 $jar->expire($name, $path);
256 }
257
258 $path = preg_replace('/.$/', '', $path);
259 } while ($path);
260 }
261
262 /**
263 * Returns current cookie path.
264 *
265 * @return string
266 */
267 private function getCookiePath()
268 {
269 $path = dirname(parse_url($this->getCurrentUrl(), PHP_URL_PATH));
270
271 if ('\\' === DIRECTORY_SEPARATOR) {
272 $path = str_replace('\\', '/', $path);
273 }
274
275 return $path;
276 }
277
278 /**
279 * {@inheritdoc}
280 */
281 public function getCookie($name)
282 {
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.
287 //
288 // $jar = $this->client->getCookieJar();
289 //
290 // if (null !== $cookie = $jar->get($name)) {
291 // return $cookie->getValue();
292 // }
293
294 $allValues = $this->client->getCookieJar()->allValues($this->getCurrentUrl());
295
296 if (isset($allValues[$name])) {
297 return $allValues[$name];
298 }
299
300 return null;
301 }
302
303 /**
304 * {@inheritdoc}
305 */
306 public function getStatusCode()
307 {
308 return $this->getResponse()->getStatus();
309 }
310
311 /**
312 * {@inheritdoc}
313 */
314 public function getContent()
315 {
316 return $this->getResponse()->getContent();
317 }
318
319 /**
320 * {@inheritdoc}
321 */
322 public function findElementXpaths($xpath)
323 {
324 $nodes = $this->getCrawler()->filterXPath($xpath);
325
326 $elements = array();
327 foreach ($nodes as $i => $node) {
328 $elements[] = sprintf('(%s)[%d]', $xpath, $i + 1);
329 }
330
331 return $elements;
332 }
333
334 /**
335 * {@inheritdoc}
336 */
337 public function getTagName($xpath)
338 {
339 return $this->getCrawlerNode($this->getFilteredCrawler($xpath))->nodeName;
340 }
341
342 /**
343 * {@inheritdoc}
344 */
345 public function getText($xpath)
346 {
347 $text = $this->getFilteredCrawler($xpath)->text();
348 $text = str_replace("\n", ' ', $text);
349 $text = preg_replace('/ {2,}/', ' ', $text);
350
351 return trim($text);
352 }
353
354 /**
355 * {@inheritdoc}
356 */
357 public function getHtml($xpath)
358 {
359 // cut the tag itself (making innerHTML out of outerHTML)
360 return preg_replace('/^\<[^\>]+\>|\<[^\>]+\>$/', '', $this->getOuterHtml($xpath));
361 }
362
363 /**
364 * {@inheritdoc}
365 */
366 public function getOuterHtml($xpath)
367 {
368 $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
369
370 return $node->ownerDocument->saveHTML($node);
371 }
372
373 /**
374 * {@inheritdoc}
375 */
376 public function getAttribute($xpath, $name)
377 {
378 $node = $this->getFilteredCrawler($xpath);
379
380 if ($this->getCrawlerNode($node)->hasAttribute($name)) {
381 return $node->attr($name);
382 }
383
384 return null;
385 }
386
387 /**
388 * {@inheritdoc}
389 */
390 public function getValue($xpath)
391 {
392 if (in_array($this->getAttribute($xpath, 'type'), array('submit', 'image', 'button'), true)) {
393 return $this->getAttribute($xpath, 'value');
394 }
395
396 $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
397
398 if ('option' === $node->tagName) {
399 return $this->getOptionValue($node);
400 }
401
402 try {
403 $field = $this->getFormField($xpath);
404 } catch (\InvalidArgumentException $e) {
405 return $this->getAttribute($xpath, 'value');
406 }
407
408 return $field->getValue();
409 }
410
411 /**
412 * {@inheritdoc}
413 */
414 public function setValue($xpath, $value)
415 {
416 $this->getFormField($xpath)->setValue($value);
417 }
418
419 /**
420 * {@inheritdoc}
421 */
422 public function check($xpath)
423 {
424 $this->getCheckboxField($xpath)->tick();
425 }
426
427 /**
428 * {@inheritdoc}
429 */
430 public function uncheck($xpath)
431 {
432 $this->getCheckboxField($xpath)->untick();
433 }
434
435 /**
436 * {@inheritdoc}
437 */
438 public function selectOption($xpath, $value, $multiple = false)
439 {
440 $field = $this->getFormField($xpath);
441
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));
444 }
445
446 if ($multiple) {
447 $oldValue = (array) $field->getValue();
448 $oldValue[] = $value;
449 $value = $oldValue;
450 }
451
452 $field->select($value);
453 }
454
455 /**
456 * {@inheritdoc}
457 */
458 public function isSelected($xpath)
459 {
460 $optionValue = $this->getOptionValue($this->getCrawlerNode($this->getFilteredCrawler($xpath)));
461 $selectField = $this->getFormField('(' . $xpath . ')/ancestor-or-self::*[local-name()="select"]');
462 $selectValue = $selectField->getValue();
463
464 return is_array($selectValue) ? in_array($optionValue, $selectValue, true) : $optionValue === $selectValue;
465 }
466
467 /**
468 * {@inheritdoc}
469 */
470 public function click($xpath)
471 {
472 $crawler = $this->getFilteredCrawler($xpath);
473 $node = $this->getCrawlerNode($crawler);
474 $tagName = $node->nodeName;
475
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);
483 } else {
484 $message = sprintf('%%s supports clicking on links and submit or reset buttons only. But "%s" provided', $tagName);
485
486 throw new UnsupportedDriverActionException($message, $this);
487 }
488 }
489
490 /**
491 * {@inheritdoc}
492 */
493 public function isChecked($xpath)
494 {
495 $field = $this->getFormField($xpath);
496
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));
499 }
500
501 if ('checkbox' === $field->getType()) {
502 return $field->hasValue();
503 }
504
505 $radio = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
506
507 return $radio->getAttribute('value') === $field->getValue();
508 }
509
510 /**
511 * {@inheritdoc}
512 */
513 public function attachFile($xpath, $path)
514 {
515 $field = $this->getFormField($xpath);
516
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));
519 }
520
521 $field->upload($path);
522 }
523
524 /**
525 * {@inheritdoc}
526 */
527 public function submitForm($xpath)
528 {
529 $crawler = $this->getFilteredCrawler($xpath);
530
531 $this->submit($crawler->form());
532 }
533
534 /**
535 * @return Response
536 *
537 * @throws DriverException If there is not response yet
538 */
539 protected function getResponse()
540 {
541 $response = $this->client->getInternalResponse();
542
543 if (null === $response) {
544 throw new DriverException('Unable to access the response before visiting a page');
545 }
546
547 return $response;
548 }
549
550 /**
551 * Prepares URL for visiting.
552 * Removes "*.php/" from urls and then passes it to BrowserKitDriver::visit().
553 *
554 * @param string $url
555 *
556 * @return string
557 */
558 protected function prepareUrl($url)
559 {
560 $replacement = ($this->removeHostFromUrl ? '' : '$1') . ($this->removeScriptFromUrl ? '' : '$2');
561
562 return preg_replace('#(https?\://[^/]+)(/[^/\.]+\.php)?#', $replacement, $url);
563 }
564
565 /**
566 * Returns form field from XPath query.
567 *
568 * @param string $xpath
569 *
570 * @return FormField
571 *
572 * @throws DriverException
573 */
574 protected function getFormField($xpath)
575 {
576 $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
577 $fieldName = str_replace('[]', '', $fieldNode->getAttribute('name'));
578
579 $formNode = $this->getFormNode($fieldNode);
580 $formId = $this->getFormNodeId($formNode);
581
582 if (!isset($this->forms[$formId])) {
583 $this->forms[$formId] = new Form($formNode, $this->getCurrentUrl());
584 }
585
586 if (is_array($this->forms[$formId][$fieldName])) {
587 return $this->forms[$formId][$fieldName][$this->getFieldPosition($fieldNode)];
588 }
589
590 return $this->forms[$formId][$fieldName];
591 }
592
593 /**
594 * Returns the checkbox field from xpath query, ensuring it is valid.
595 *
596 * @param string $xpath
597 *
598 * @return ChoiceFormField
599 *
600 * @throws DriverException when the field is not a checkbox
601 */
602 private function getCheckboxField($xpath)
603 {
604 $field = $this->getFormField($xpath);
605
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));
608 }
609
610 return $field;
611 }
612
613 /**
614 * @param \DOMElement $element
615 *
616 * @return \DOMElement
617 *
618 * @throws DriverException if the form node cannot be found
619 */
620 private function getFormNode(\DOMElement $element)
621 {
622 if ($element->hasAttribute('form')) {
623 $formId = $element->getAttribute('form');
624 $formNode = $element->ownerDocument->getElementById($formId);
625
626 if (null === $formNode || 'form' !== $formNode->nodeName) {
627 throw new DriverException(sprintf('The selected node has an invalid form attribute (%s).', $formId));
628 }
629
630 return $formNode;
631 }
632
633 $formNode = $element;
634
635 do {
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.');
639 }
640 } while ('form' !== $formNode->nodeName);
641
642 return $formNode;
643 }
644
645 /**
646 * Gets the position of the field node among elements with the same name
647 *
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.
651 *
652 * @param \DOMElement $fieldNode
653 *
654 * @return integer
655 */
656 private function getFieldPosition(\DOMElement $fieldNode)
657 {
658 $elements = $this->getCrawler()->filterXPath('//*[@name=\''.$fieldNode->getAttribute('name').'\']');
659
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()) {
666 return $key;
667 }
668 }
669 }
670
671 return 0;
672 }
673
674 private function submit(Form $form)
675 {
676 $formId = $this->getFormNodeId($form->getFormNode());
677
678 if (isset($this->forms[$formId])) {
679 $this->mergeForms($form, $this->forms[$formId]);
680 }
681
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);
686 }
687 }
688
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('');
693 }
694 }
695
696 $this->client->submit($form);
697
698 $this->forms = array();
699 }
700
701 private function resetForm(\DOMElement $fieldNode)
702 {
703 $formNode = $this->getFormNode($fieldNode);
704 $formId = $this->getFormNodeId($formNode);
705 unset($this->forms[$formId]);
706 }
707
708 /**
709 * Determines if a node can submit a form.
710 *
711 * @param \DOMElement $node Node.
712 *
713 * @return boolean
714 */
715 private function canSubmitForm(\DOMElement $node)
716 {
717 $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
718
719 if ('input' === $node->nodeName && in_array($type, array('submit', 'image'), true)) {
720 return true;
721 }
722
723 return 'button' === $node->nodeName && (null === $type || 'submit' === $type);
724 }
725
726 /**
727 * Determines if a node can reset a form.
728 *
729 * @param \DOMElement $node Node.
730 *
731 * @return boolean
732 */
733 private function canResetForm(\DOMElement $node)
734 {
735 $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
736
737 return in_array($node->nodeName, array('input', 'button'), true) && 'reset' === $type;
738 }
739
740 /**
741 * Returns form node unique identifier.
742 *
743 * @param \DOMElement $form
744 *
745 * @return string
746 */
747 private function getFormNodeId(\DOMElement $form)
748 {
749 return md5($form->getLineNo() . $form->getNodePath() . $form->nodeValue);
750 }
751
752 /**
753 * Gets the value of an option element
754 *
755 * @param \DOMElement $option
756 *
757 * @return string
758 *
759 * @see \Symfony\Component\DomCrawler\Field\ChoiceFormField::buildOptionValue
760 */
761 private function getOptionValue(\DOMElement $option)
762 {
763 if ($option->hasAttribute('value')) {
764 return $option->getAttribute('value');
765 }
766
767 if (!empty($option->nodeValue)) {
768 return $option->nodeValue;
769 }
770
771 return '1'; // DomCrawler uses 1 by default if there is no text in the option
772 }
773
774 /**
775 * Merges second form values into first one.
776 *
777 * @param Form $to merging target
778 * @param Form $from merging source
779 */
780 private function mergeForms(Form $to, Form $from)
781 {
782 foreach ($from->all() as $name => $field) {
783 $fieldReflection = new \ReflectionObject($field);
784 $nodeReflection = $fieldReflection->getProperty('node');
785 $valueReflection = $fieldReflection->getProperty('value');
786
787 $nodeReflection->setAccessible(true);
788 $valueReflection->setAccessible(true);
789
790 $isIgnoredField = $field instanceof InputFormField &&
791 in_array($nodeReflection->getValue($field)->getAttribute('type'), array('submit', 'button', 'image'), true);
792
793 if (!$isIgnoredField) {
794 $valueReflection->setValue($to[$name], $valueReflection->getValue($field));
795 }
796 }
797 }
798
799 /**
800 * Returns DOMElement from crawler instance.
801 *
802 * @param Crawler $crawler
803 *
804 * @return \DOMElement
805 *
806 * @throws DriverException when the node does not exist
807 */
808 private function getCrawlerNode(Crawler $crawler)
809 {
810 $node = null;
811
812 if ($crawler instanceof \Iterator) {
813 // for symfony 2.3 compatibility as getNode is not public before symfony 2.4
814 $crawler->rewind();
815 $node = $crawler->current();
816 } else {
817 $node = $crawler->getNode(0);
818 }
819
820 if (null !== $node) {
821 return $node;
822 }
823
824 throw new DriverException('The element does not exist');
825 }
826
827 /**
828 * Returns a crawler filtered for the given XPath, requiring at least 1 result.
829 *
830 * @param string $xpath
831 *
832 * @return Crawler
833 *
834 * @throws DriverException when no matching elements are found
835 */
836 private function getFilteredCrawler($xpath)
837 {
838 if (!count($crawler = $this->getCrawler()->filterXPath($xpath))) {
839 throw new DriverException(sprintf('There is no element matching XPath "%s"', $xpath));
840 }
841
842 return $crawler;
843 }
844
845 /**
846 * Returns crawler instance (got from client).
847 *
848 * @return Crawler
849 *
850 * @throws DriverException
851 */
852 private function getCrawler()
853 {
854 $crawler = $this->client->getCrawler();
855
856 if (null === $crawler) {
857 throw new DriverException('Unable to access the response content before visiting a page');
858 }
859
860 return $crawler;
861 }
862 }