BrowserKitDriver::visit()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 4
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Behat\Mink.
5
 * (c) Konstantin Kudryashov <[email protected]>
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 Zenstruck\Browser\Mink;
12
13
use Behat\Mink\Driver\CoreDriver;
14
use Behat\Mink\Exception\DriverException;
15
use Behat\Mink\Exception\UnsupportedDriverActionException;
16
use Symfony\Component\BrowserKit\AbstractBrowser;
17
use Symfony\Component\BrowserKit\Cookie;
18
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
19
use Symfony\Component\BrowserKit\Response;
20
use Symfony\Component\DomCrawler\Crawler;
21
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
22
use Symfony\Component\DomCrawler\Field\FileFormField;
23
use Symfony\Component\DomCrawler\Field\FormField;
24
use Symfony\Component\DomCrawler\Field\InputFormField;
25
use Symfony\Component\DomCrawler\Field\TextareaFormField;
26
use Symfony\Component\DomCrawler\Form;
27
28
/**
29
 * Copied from https://github.com/minkphp/MinkBrowserKitDriver for use
30
 * until it supports Symfony 5/PHP 8.
31
 *
32
 * @ref https://github.com/minkphp/MinkBrowserKitDriver
33
 * @ref https://github.com/minkphp/MinkBrowserKitDriver/pull/151
34
 *
35
 * @author Konstantin Kudryashov <[email protected]>
36
 *
37
 * @internal
38
 */
39
final class BrowserKitDriver extends CoreDriver
40
{
41
    private AbstractBrowser $client;
42
43
    /** @var Form[] */
44
    private array $forms = [];
45
46
    /** @var array<string,string> */
47
    private array $serverParameters = [];
48
    private bool $started = false;
49
50
    public function __construct(AbstractBrowser $client)
51
    {
52
        $this->client = $client;
53
        $this->client->followRedirects(true);
54
    }
55
56
    public function start(): void
57
    {
58
        $this->started = true;
59
    }
60
61
    public function isStarted(): bool
62
    {
63
        return $this->started;
64
    }
65
66
    public function stop(): void
67
    {
68
        $this->reset();
69
        $this->started = false;
70
    }
71
72
    public function reset(): void
73
    {
74
        // Restarting the client resets the cookies and the history
75
        $this->client->restart();
76
        $this->forms = [];
77
        $this->serverParameters = [];
78
    }
79
80
    public function visit($url): void
81
    {
82
        $this->client->request('GET', $url, [], [], $this->serverParameters);
83
        $this->forms = [];
84
    }
85
86
    public function getCurrentUrl(): string
87
    {
88
        // This should be encapsulated in `getRequest` method if any other method needs the request
89
        try {
90
            $request = $this->client->getInternalRequest();
91
        } catch (BadMethodCallException $e) {
92
            // Handling Symfony 5+ behaviour
93
            $request = null;
94
        }
95
96
        if (null === $request) {
97
            throw new DriverException('Unable to access the request before visiting a page');
98
        }
99
100
        return $request->getUri();
101
    }
102
103
    public function reload(): void
104
    {
105
        $this->client->reload();
106
        $this->forms = [];
107
    }
108
109
    public function forward(): void
110
    {
111
        $this->client->forward();
112
        $this->forms = [];
113
    }
114
115
    public function back(): void
116
    {
117
        $this->client->back();
118
        $this->forms = [];
119
    }
120
121
    /**
122
     * @param false|string $user
123
     */
124
    public function setBasicAuth($user, $password): void
125
    {
126
        if (false === $user) {
127
            unset($this->serverParameters['PHP_AUTH_USER'], $this->serverParameters['PHP_AUTH_PW']);
128
129
            return;
130
        }
131
132
        $this->serverParameters['PHP_AUTH_USER'] = $user;
133
        $this->serverParameters['PHP_AUTH_PW'] = $password;
134
    }
135
136
    public function setRequestHeader($name, $value): void
137
    {
138
        $contentHeaders = ['CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true];
139
        $name = \str_replace('-', '_', \mb_strtoupper($name));
140
141
        // CONTENT_* are not prefixed with HTTP_ in PHP when building $_SERVER
142
        if (!isset($contentHeaders[$name])) {
143
            $name = 'HTTP_'.$name;
144
        }
145
146
        $this->serverParameters[$name] = $value;
147
    }
148
149
    public function getResponseHeaders(): array
150
    {
151
        return $this->getResponse()->getHeaders();
152
    }
153
154
    public function setCookie($name, $value = null): void
155
    {
156
        if (null === $value) {
157
            $this->deleteCookie($name);
158
159
            return;
160
        }
161
162
        $jar = $this->client->getCookieJar();
163
        $jar->set(new Cookie($name, $value));
164
    }
165
166
    public function getCookie($name): ?string
167
    {
168
        // Note that the following doesn't work well because
169
        // Symfony\Component\BrowserKit\CookieJar stores cookies by name,
170
        // path, AND domain and if you don't fill them all in correctly then
171
        // you won't get the value that you're expecting.
172
        //
173
        // $jar = $this->client->getCookieJar();
174
        //
175
        // if (null !== $cookie = $jar->get($name)) {
176
        //     return $cookie->getValue();
177
        // }
178
179
        $allValues = $this->client->getCookieJar()->allValues($this->getCurrentUrl());
180
181
        if (isset($allValues[$name])) {
182
            return $allValues[$name];
183
        }
184
185
        return null;
186
    }
187
188
    public function getStatusCode(): int
189
    {
190
        return $this->getResponse()->getStatusCode();
191
    }
192
193
    public function getContent(): string
194
    {
195
        return $this->getResponse()->getContent();
196
    }
197
198
    public function getTagName($xpath): string
199
    {
200
        return $this->getCrawlerNode($this->getFilteredCrawler($xpath))->nodeName;
201
    }
202
203
    public function getText($xpath): string
204
    {
205
        return \trim($this->getFilteredCrawler($xpath)->text(null, true));
206
    }
207
208
    public function getHtml($xpath): string
209
    {
210
        return $this->getFilteredCrawler($xpath)->html();
211
    }
212
213
    public function getOuterHtml($xpath): string
214
    {
215
        return $this->getFilteredCrawler($xpath)->outerHtml();
216
    }
217
218
    public function getAttribute($xpath, $name): ?string
219
    {
220
        $node = $this->getFilteredCrawler($xpath);
221
222
        if ($this->getCrawlerNode($node)->hasAttribute($name)) {
223
            return $node->attr($name);
224
        }
225
226
        return null;
227
    }
228
229
    public function getValue($xpath)
230
    {
231
        if (\in_array($this->getAttribute($xpath, 'type'), ['submit', 'image', 'button'], true)) {
232
            return $this->getAttribute($xpath, 'value');
233
        }
234
235
        $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
236
237
        if ('option' === $node->tagName) {
238
            return $this->getOptionValue($node);
239
        }
240
241
        try {
242
            $field = $this->getFormField($xpath);
243
        } catch (\InvalidArgumentException $e) {
244
            return $this->getAttribute($xpath, 'value');
245
        }
246
247
        $value = $field->getValue();
248
249
        if ('select' === $node->tagName && null === $value) {
0 ignored issues
show
introduced by
The condition null === $value is always false.
Loading history...
250
            // symfony/dom-crawler returns null as value for a non-multiple select without
251
            // options but we want an empty string to match browsers.
252
            $value = '';
253
        }
254
255
        return $value;
256
    }
257
258
    public function setValue($xpath, $value): void
259
    {
260
        $this->getFormField($xpath)->setValue($value);
261
    }
262
263
    public function check($xpath): void
264
    {
265
        $this->getCheckboxField($xpath)->tick();
266
    }
267
268
    public function uncheck($xpath): void
269
    {
270
        $this->getCheckboxField($xpath)->untick();
271
    }
272
273
    public function selectOption($xpath, $value, $multiple = false): void
274
    {
275
        $field = $this->getFormField($xpath);
276
277
        if (!$field instanceof ChoiceFormField) {
278
            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));
279
        }
280
281
        if ($multiple) {
282
            $oldValue = (array) $field->getValue();
283
            $oldValue[] = $value;
284
            $value = $oldValue;
285
        }
286
287
        $field->select($value);
288
    }
289
290
    public function isSelected($xpath): bool
291
    {
292
        $optionValue = $this->getOptionValue($this->getCrawlerNode($this->getFilteredCrawler($xpath)));
293
        $selectField = $this->getFormField('('.$xpath.')/ancestor-or-self::*[local-name()="select"]');
294
        $selectValue = $selectField->getValue();
295
296
        return \is_array($selectValue) ? \in_array($optionValue, $selectValue, true) : $optionValue === $selectValue;
0 ignored issues
show
introduced by
The condition is_array($selectValue) is always false.
Loading history...
297
    }
298
299
    public function click($xpath): void
300
    {
301
        $crawler = $this->getFilteredCrawler($xpath);
302
        $node = $this->getCrawlerNode($crawler);
303
        $tagName = $node->nodeName;
304
305
        if ('a' === $tagName) {
306
            $this->client->click($crawler->link());
307
            $this->forms = [];
308
        } elseif ($this->canSubmitForm($node)) {
309
            $this->submit($crawler->form());
310
        } elseif ($this->canResetForm($node)) {
311
            $this->resetForm($node);
312
        } else {
313
            $message = \sprintf('%%s supports clicking on links and submit or reset buttons only. But "%s" provided', $tagName);
314
315
            throw new UnsupportedDriverActionException($message, $this);
316
        }
317
    }
318
319
    public function isChecked($xpath): bool
320
    {
321
        $field = $this->getFormField($xpath);
322
323
        if (!$field instanceof ChoiceFormField || 'select' === $field->getType()) {
324
            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));
325
        }
326
327
        if ('checkbox' === $field->getType()) {
328
            return $field->hasValue();
329
        }
330
331
        $radio = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
332
333
        return $radio->getAttribute('value') === $field->getValue();
334
    }
335
336
    /**
337
     * @param string|string[] $path
338
     */
339
    public function attachFile($xpath, $path): void
340
    {
341
        $files = (array) $path;
342
        $field = $this->getFormField($xpath);
343
344
        if (!$field instanceof FileFormField) {
345
            throw new DriverException(\sprintf('Impossible to attach a file on the element with XPath "%s" as it is not a file input', $xpath));
346
        }
347
348
        $field->upload(\array_shift($files));
349
350
        if (!$files) {
351
            // not multiple files
352
            return;
353
        }
354
355
        $node = $this->getFilteredCrawler($xpath);
356
357
        if (null === $node->attr('multiple')) {
358
            throw new \InvalidArgumentException('Cannot attach multiple files to a non-multiple file field.');
359
        }
360
361
        $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
362
        $form = $this->getFormForFieldNode($fieldNode);
363
364
        foreach ($files as $file) {
365
            $field = new FileFormField($fieldNode);
366
            $field->upload($file);
367
368
            $form->set($field);
369
        }
370
    }
371
372
    public function submitForm($xpath): void
373
    {
374
        $crawler = $this->getFilteredCrawler($xpath);
375
376
        $this->submit($crawler->form());
377
    }
378
379
    protected function findElementXpaths($xpath): array
380
    {
381
        $nodes = $this->getCrawler()->filterXPath($xpath);
382
383
        $elements = [];
384
        foreach ($nodes as $i => $node) {
385
            $elements[] = \sprintf('(%s)[%d]', $xpath, $i + 1);
386
        }
387
388
        return $elements;
389
    }
390
391
    private function getResponse(): Response
392
    {
393
        try {
394
            $response = $this->client->getInternalResponse();
395
        } catch (BadMethodCallException $e) {
396
            // Handling Symfony 5+ behaviour
397
            $response = null;
398
        }
399
400
        if (null === $response) {
401
            throw new DriverException('Unable to access the response before visiting a page');
402
        }
403
404
        return $response;
405
    }
406
407
    private function getFormField(string $xpath): FormField
408
    {
409
        $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
410
        $fieldName = \str_replace('[]', '', $fieldNode->getAttribute('name'));
411
412
        $form = $this->getFormForFieldNode($fieldNode);
413
414
        if (\is_array($form[$fieldName])) {
415
            return $form[$fieldName][$this->getFieldPosition($fieldNode)];
416
        }
417
418
        return $form[$fieldName];
0 ignored issues
show
Bug Best Practice introduced by
The expression return $form[$fieldName] could return the type Symfony\Component\DomCra...wler\Field\FormField[]> which is incompatible with the type-hinted return Symfony\Component\DomCrawler\Field\FormField. Consider adding an additional type-check to rule them out.
Loading history...
419
    }
420
421
    private function getFormForFieldNode(\DOMElement $fieldNode): Form
422
    {
423
        $formNode = $this->getFormNode($fieldNode);
424
        $formId = $this->getFormNodeId($formNode);
425
426
        if (!isset($this->forms[$formId])) {
427
            $this->forms[$formId] = new Form($formNode, $this->getCurrentUrl());
428
        }
429
430
        return $this->forms[$formId];
431
    }
432
433
    /**
434
     * Deletes a cookie by name.
435
     */
436
    private function deleteCookie(string $name): void
437
    {
438
        $path = $this->getCookiePath();
439
        $jar = $this->client->getCookieJar();
440
441
        do {
442
            if (null !== $jar->get($name, $path)) {
443
                $jar->expire($name, $path);
444
            }
445
446
            $path = \preg_replace('/.$/', '', $path);
447
        } while ($path);
448
    }
449
450
    /**
451
     * Returns current cookie path.
452
     */
453
    private function getCookiePath(): string
454
    {
455
        $path = \dirname(\parse_url($this->getCurrentUrl(), \PHP_URL_PATH));
456
457
        if ('\\' === \DIRECTORY_SEPARATOR) {
458
            $path = \str_replace('\\', '/', $path);
459
        }
460
461
        return $path;
462
    }
463
464
    /**
465
     * Returns the checkbox field from xpath query, ensuring it is valid.
466
     *
467
     * @throws DriverException when the field is not a checkbox
468
     */
469
    private function getCheckboxField(string $xpath): ChoiceFormField
470
    {
471
        $field = $this->getFormField($xpath);
472
473
        if (!$field instanceof ChoiceFormField) {
474
            throw new DriverException(\sprintf('Impossible to check the element with XPath "%s" as it is not a checkbox', $xpath));
475
        }
476
477
        return $field;
478
    }
479
480
    /**
481
     * @throws DriverException if the form node cannot be found
482
     */
483
    private function getFormNode(\DOMElement $element): \DOMElement
484
    {
485
        if ($element->hasAttribute('form')) {
486
            $formId = $element->getAttribute('form');
487
488
            if (!$element->ownerDocument) {
489
                throw new DriverException(\sprintf('The selected node has an invalid form attribute (%s).', $formId));
490
            }
491
492
            $formNode = $element->ownerDocument->getElementById($formId);
493
494
            if (null === $formNode || 'form' !== $formNode->nodeName) {
495
                throw new DriverException(\sprintf('The selected node has an invalid form attribute (%s).', $formId));
496
            }
497
498
            return $formNode;
499
        }
500
501
        $formNode = $element;
502
503
        do {
504
            // use the ancestor form element
505
            if (null === $formNode = $formNode->parentNode) {
506
                throw new DriverException('The selected node does not have a form ancestor.');
507
            }
508
        } while ('form' !== $formNode->nodeName);
509
510
        return $formNode;
511
    }
512
513
    /**
514
     * Gets the position of the field node among elements with the same name.
515
     *
516
     * BrowserKit uses the field name as index to find the field in its Form object.
517
     * When multiple fields have the same name (checkboxes for instance), it will return
518
     * an array of elements in the order they appear in the DOM.
519
     */
520
    private function getFieldPosition(\DOMElement $fieldNode): int
521
    {
522
        $elements = $this->getCrawler()->filterXPath('//*[@name=\''.$fieldNode->getAttribute('name').'\']');
523
524
        if (\count($elements) > 1) {
525
            // more than one element contains this name !
526
            // so we need to find the position of $fieldNode
527
            foreach ($elements as $key => $element) {
528
                /** @var \DOMElement $element */
529
                if ($element->getNodePath() === $fieldNode->getNodePath()) {
530
                    return $key;
531
                }
532
            }
533
        }
534
535
        return 0;
536
    }
537
538
    private function submit(Form $form): void
539
    {
540
        $formId = $this->getFormNodeId($form->getFormNode());
541
542
        if (isset($this->forms[$formId])) {
543
            $form = $this->addButtons($form, $this->forms[$formId]);
544
        }
545
546
        // remove empty file fields from request
547
        foreach ($form->getFiles() as $name => $field) {
548
            if (empty($field['name']) && empty($field['tmp_name'])) {
549
                $form->remove($name);
550
            }
551
        }
552
553
        foreach ($form->all() as $field) {
554
            // Add a fix for https://github.com/symfony/symfony/pull/10733 to support Symfony versions which are not fixed
555
            if ($field instanceof TextareaFormField && null === $field->getValue()) {
556
                $field->setValue('');
557
            }
558
        }
559
560
        $this->client->submit($form, [], $this->serverParameters);
561
562
        $this->forms = [];
563
    }
564
565
    private function resetForm(\DOMElement $fieldNode): void
566
    {
567
        $formNode = $this->getFormNode($fieldNode);
568
        $formId = $this->getFormNodeId($formNode);
569
        unset($this->forms[$formId]);
570
    }
571
572
    /**
573
     * Determines if a node can submit a form.
574
     */
575
    private function canSubmitForm(\DOMElement $node): bool
576
    {
577
        $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
578
579
        if ('input' === $node->nodeName && \in_array($type, ['submit', 'image'], true)) {
580
            return true;
581
        }
582
583
        return 'button' === $node->nodeName && (null === $type || 'submit' === $type);
584
    }
585
586
    /**
587
     * Determines if a node can reset a form.
588
     */
589
    private function canResetForm(\DOMElement $node): bool
590
    {
591
        $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;
592
593
        return \in_array($node->nodeName, ['input', 'button'], true) && 'reset' === $type;
594
    }
595
596
    /**
597
     * Returns form node unique identifier.
598
     */
599
    private function getFormNodeId(\DOMElement $form): string
600
    {
601
        return \md5($form->getLineNo().$form->getNodePath().$form->nodeValue);
602
    }
603
604
    /**
605
     * Gets the value of an option element.
606
     *
607
     * @see ChoiceFormField::buildOptionValue()
608
     */
609
    private function getOptionValue(\DOMElement $option): string
610
    {
611
        if ($option->hasAttribute('value')) {
612
            return $option->getAttribute('value');
613
        }
614
615
        if (!empty($option->nodeValue)) {
616
            return $option->nodeValue;
617
        }
618
619
        return '1'; // DomCrawler uses 1 by default if there is no text in the option
620
    }
621
622
    /**
623
     * Returns DOMElement from crawler instance.
624
     *
625
     * @throws DriverException when the node does not exist
626
     */
627
    private function getCrawlerNode(Crawler $crawler): \DOMElement
628
    {
629
        if ($crawler instanceof \Iterator) {
630
            // for symfony 2.3 compatibility as getNode is not public before symfony 2.4
631
            $crawler->rewind();
632
            $node = $crawler->current();
633
        } else {
634
            $node = $crawler->getNode(0);
635
        }
636
637
        if (null !== $node) {
638
            return $node;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $node could return the type DOMNode which includes types incompatible with the type-hinted return DOMElement. Consider adding an additional type-check to rule them out.
Loading history...
639
        }
640
641
        throw new DriverException('The element does not exist');
642
    }
643
644
    /**
645
     * Returns a crawler filtered for the given XPath, requiring at least 1 result.
646
     *
647
     * @throws DriverException when no matching elements are found
648
     */
649
    private function getFilteredCrawler(string $xpath): Crawler
650
    {
651
        if (!\count($crawler = $this->getCrawler()->filterXPath($xpath))) {
652
            throw new DriverException(\sprintf('There is no element matching XPath "%s"', $xpath));
653
        }
654
655
        return $crawler;
656
    }
657
658
    /**
659
     * Returns crawler instance (got from client).
660
     */
661
    private function getCrawler(): Crawler
662
    {
663
        try {
664
            return $this->client->getCrawler();
665
        } catch (BadMethodCallException $e) {
666
            throw new DriverException('Unable to access the response content before visiting a page', 0, $e);
667
        }
668
    }
669
670
    /**
671
     * Adds button fields from submitted form to cached version.
672
     */
673
    private function addButtons(Form $submitted, Form $cached): Form
674
    {
675
        foreach ($submitted->all() as $field) {
676
            if (!$field instanceof InputFormField) {
677
                continue;
678
            }
679
680
            $nodeReflection = (new \ReflectionObject($field))->getProperty('node');
681
            $nodeReflection->setAccessible(true);
682
683
            $node = $nodeReflection->getValue($field);
684
685
            if ('button' === $node->nodeName || \in_array($node->getAttribute('type'), ['submit', 'button', 'image'])) {
686
                $cached->set($field);
687
            }
688
        }
689
690
        return $cached;
691
    }
692
}
693