cea8125961a7f728a197f7a9010011061fc46455
[yaffs-website] / BrowserKitDriver.php
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 }