1<?php
2
3namespace Facebook\WebDriver\Remote;
4
5use Facebook\WebDriver\Exception\ElementNotInteractableException;
6use Facebook\WebDriver\Exception\UnsupportedOperationException;
7use Facebook\WebDriver\Exception\WebDriverException;
8use Facebook\WebDriver\Interactions\Internal\WebDriverCoordinates;
9use Facebook\WebDriver\Internal\WebDriverLocatable;
10use Facebook\WebDriver\WebDriverBy;
11use Facebook\WebDriver\WebDriverDimension;
12use Facebook\WebDriver\WebDriverElement;
13use Facebook\WebDriver\WebDriverKeys;
14use Facebook\WebDriver\WebDriverPoint;
15use ZipArchive;
16
17/**
18 * Represents an HTML element.
19 */
20class RemoteWebElement implements WebDriverElement, WebDriverLocatable
21{
22    /**
23     * @var RemoteExecuteMethod
24     */
25    protected $executor;
26    /**
27     * @var string
28     */
29    protected $id;
30    /**
31     * @var FileDetector
32     */
33    protected $fileDetector;
34    /**
35     * @var bool
36     */
37    protected $isW3cCompliant;
38
39    /**
40     * @param RemoteExecuteMethod $executor
41     * @param string $id
42     * @param bool $isW3cCompliant
43     */
44    public function __construct(RemoteExecuteMethod $executor, $id, $isW3cCompliant = false)
45    {
46        $this->executor = $executor;
47        $this->id = $id;
48        $this->fileDetector = new UselessFileDetector();
49        $this->isW3cCompliant = $isW3cCompliant;
50    }
51
52    /**
53     * Clear content editable or resettable element
54     *
55     * @return RemoteWebElement The current instance.
56     */
57    public function clear()
58    {
59        $this->executor->execute(
60            DriverCommand::CLEAR_ELEMENT,
61            [':id' => $this->id]
62        );
63
64        return $this;
65    }
66
67    /**
68     * Click this element.
69     *
70     * @return RemoteWebElement The current instance.
71     */
72    public function click()
73    {
74        try {
75            $this->executor->execute(
76                DriverCommand::CLICK_ELEMENT,
77                [':id' => $this->id]
78            );
79        } catch (ElementNotInteractableException $e) {
80            // An issue with geckodriver (https://github.com/mozilla/geckodriver/issues/653) prevents clicking on a link
81            // if the first child is a block-level element.
82            // The workaround in this case is to click on a child element.
83            $this->clickChildElement($e);
84        }
85
86        return $this;
87    }
88
89    /**
90     * Find the first WebDriverElement within this element using the given mechanism.
91     *
92     * When using xpath be aware that webdriver follows standard conventions: a search prefixed with "//" will
93     * search the entire document from the root, not just the children (relative context) of this current node.
94     * Use ".//" to limit your search to the children of this element.
95     *
96     * @param WebDriverBy $by
97     * @return RemoteWebElement NoSuchElementException is thrown in HttpCommandExecutor if no element is found.
98     * @see WebDriverBy
99     */
100    public function findElement(WebDriverBy $by)
101    {
102        $params = JsonWireCompat::getUsing($by, $this->isW3cCompliant);
103        $params[':id'] = $this->id;
104
105        $raw_element = $this->executor->execute(
106            DriverCommand::FIND_CHILD_ELEMENT,
107            $params
108        );
109
110        return $this->newElement(JsonWireCompat::getElement($raw_element));
111    }
112
113    /**
114     * Find all WebDriverElements within this element using the given mechanism.
115     *
116     * When using xpath be aware that webdriver follows standard conventions: a search prefixed with "//" will
117     * search the entire document from the root, not just the children (relative context) of this current node.
118     * Use ".//" to limit your search to the children of this element.
119     *
120     * @param WebDriverBy $by
121     * @return RemoteWebElement[] A list of all WebDriverElements, or an empty
122     *    array if nothing matches
123     * @see WebDriverBy
124     */
125    public function findElements(WebDriverBy $by)
126    {
127        $params = JsonWireCompat::getUsing($by, $this->isW3cCompliant);
128        $params[':id'] = $this->id;
129        $raw_elements = $this->executor->execute(
130            DriverCommand::FIND_CHILD_ELEMENTS,
131            $params
132        );
133
134        $elements = [];
135        foreach ($raw_elements as $raw_element) {
136            $elements[] = $this->newElement(JsonWireCompat::getElement($raw_element));
137        }
138
139        return $elements;
140    }
141
142    /**
143     * Get the value of the given attribute of the element.
144     * Attribute is meant what is declared in the HTML markup of the element.
145     * To read a value of a IDL "JavaScript" property (like `innerHTML`), use `getDomProperty()` method.
146     *
147     * @param string $attribute_name The name of the attribute.
148     * @return string|true|null The value of the attribute. If this is boolean attribute, return true if the element
149     *      has it, otherwise return null.
150     */
151    public function getAttribute($attribute_name)
152    {
153        $params = [
154            ':name' => $attribute_name,
155            ':id' => $this->id,
156        ];
157
158        if ($this->isW3cCompliant && ($attribute_name === 'value' || $attribute_name === 'index')) {
159            $value = $this->executor->execute(DriverCommand::GET_ELEMENT_PROPERTY, $params);
160
161            if ($value === true) {
162                return 'true';
163            }
164
165            if ($value === false) {
166                return 'false';
167            }
168
169            if ($value !== null) {
170                return (string) $value;
171            }
172        }
173
174        return $this->executor->execute(DriverCommand::GET_ELEMENT_ATTRIBUTE, $params);
175    }
176
177    /**
178     * Gets the value of a IDL JavaScript property of this element (for example `innerHTML`, `tagName` etc.).
179     *
180     * @see https://developer.mozilla.org/en-US/docs/Glossary/IDL
181     * @see https://developer.mozilla.org/en-US/docs/Web/API/Element#properties
182     * @param string $propertyName
183     * @return mixed|null The property's current value or null if the value is not set or the property does not exist.
184     */
185    public function getDomProperty($propertyName)
186    {
187        if (!$this->isW3cCompliant) {
188            throw new UnsupportedOperationException('This method is only supported in W3C mode');
189        }
190
191        $params = [
192            ':name' => $propertyName,
193            ':id' => $this->id,
194        ];
195
196        return $this->executor->execute(DriverCommand::GET_ELEMENT_PROPERTY, $params);
197    }
198
199    /**
200     * Get the value of a given CSS property.
201     *
202     * @param string $css_property_name The name of the CSS property.
203     * @return string The value of the CSS property.
204     */
205    public function getCSSValue($css_property_name)
206    {
207        $params = [
208            ':propertyName' => $css_property_name,
209            ':id' => $this->id,
210        ];
211
212        return $this->executor->execute(
213            DriverCommand::GET_ELEMENT_VALUE_OF_CSS_PROPERTY,
214            $params
215        );
216    }
217
218    /**
219     * Get the location of element relative to the top-left corner of the page.
220     *
221     * @return WebDriverPoint The location of the element.
222     */
223    public function getLocation()
224    {
225        $location = $this->executor->execute(
226            DriverCommand::GET_ELEMENT_LOCATION,
227            [':id' => $this->id]
228        );
229
230        return new WebDriverPoint($location['x'], $location['y']);
231    }
232
233    /**
234     * Try scrolling the element into the view port and return the location of
235     * element relative to the top-left corner of the page afterwards.
236     *
237     * @return WebDriverPoint The location of the element.
238     */
239    public function getLocationOnScreenOnceScrolledIntoView()
240    {
241        if ($this->isW3cCompliant) {
242            $script = <<<JS
243var e = arguments[0];
244e.scrollIntoView({ behavior: 'instant', block: 'end', inline: 'nearest' });
245var rect = e.getBoundingClientRect();
246return {'x': rect.left, 'y': rect.top};
247JS;
248
249            $result = $this->executor->execute(DriverCommand::EXECUTE_SCRIPT, [
250                'script' => $script,
251                'args' => [[JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $this->id]],
252            ]);
253            $location = ['x' => $result['x'], 'y' => $result['y']];
254        } else {
255            $location = $this->executor->execute(
256                DriverCommand::GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW,
257                [':id' => $this->id]
258            );
259        }
260
261        return new WebDriverPoint($location['x'], $location['y']);
262    }
263
264    /**
265     * @return WebDriverCoordinates
266     */
267    public function getCoordinates()
268    {
269        $element = $this;
270
271        $on_screen = null; // planned but not yet implemented
272        $in_view_port = static function () use ($element) {
273            return $element->getLocationOnScreenOnceScrolledIntoView();
274        };
275        $on_page = static function () use ($element) {
276            return $element->getLocation();
277        };
278        $auxiliary = $this->getID();
279
280        return new WebDriverCoordinates(
281            $on_screen,
282            $in_view_port,
283            $on_page,
284            $auxiliary
285        );
286    }
287
288    /**
289     * Get the size of element.
290     *
291     * @return WebDriverDimension The dimension of the element.
292     */
293    public function getSize()
294    {
295        $size = $this->executor->execute(
296            DriverCommand::GET_ELEMENT_SIZE,
297            [':id' => $this->id]
298        );
299
300        return new WebDriverDimension($size['width'], $size['height']);
301    }
302
303    /**
304     * Get the (lowercase) tag name of this element.
305     *
306     * @return string The tag name.
307     */
308    public function getTagName()
309    {
310        // Force tag name to be lowercase as expected by JsonWire protocol for Opera driver
311        // until this issue is not resolved :
312        // https://github.com/operasoftware/operadriver/issues/102
313        // Remove it when fixed to be consistent with the protocol.
314        return mb_strtolower($this->executor->execute(
315            DriverCommand::GET_ELEMENT_TAG_NAME,
316            [':id' => $this->id]
317        ));
318    }
319
320    /**
321     * Get the visible (i.e. not hidden by CSS) innerText of this element,
322     * including sub-elements, without any leading or trailing whitespace.
323     *
324     * @return string The visible innerText of this element.
325     */
326    public function getText()
327    {
328        return $this->executor->execute(
329            DriverCommand::GET_ELEMENT_TEXT,
330            [':id' => $this->id]
331        );
332    }
333
334    /**
335     * Is this element displayed or not? This method avoids the problem of having
336     * to parse an element's "style" attribute.
337     *
338     * @return bool
339     */
340    public function isDisplayed()
341    {
342        return $this->executor->execute(
343            DriverCommand::IS_ELEMENT_DISPLAYED,
344            [':id' => $this->id]
345        );
346    }
347
348    /**
349     * Is the element currently enabled or not? This will generally return true
350     * for everything but disabled input elements.
351     *
352     * @return bool
353     */
354    public function isEnabled()
355    {
356        return $this->executor->execute(
357            DriverCommand::IS_ELEMENT_ENABLED,
358            [':id' => $this->id]
359        );
360    }
361
362    /**
363     * Determine whether this element is selected or not.
364     *
365     * @return bool
366     */
367    public function isSelected()
368    {
369        return $this->executor->execute(
370            DriverCommand::IS_ELEMENT_SELECTED,
371            [':id' => $this->id]
372        );
373    }
374
375    /**
376     * Simulate typing into an element, which may set its value.
377     *
378     * @param mixed $value The data to be typed.
379     * @return RemoteWebElement The current instance.
380     */
381    public function sendKeys($value)
382    {
383        $local_file = $this->fileDetector->getLocalFile($value);
384
385        $params = [];
386        if ($local_file === null) {
387            if ($this->isW3cCompliant) {
388                // Work around the Geckodriver NULL issue by splitting on NULL and calling sendKeys multiple times.
389                // See https://bugzilla.mozilla.org/show_bug.cgi?id=1494661.
390                $encodedValues = explode(WebDriverKeys::NULL, WebDriverKeys::encode($value, true));
391                foreach ($encodedValues as $encodedValue) {
392                    $params[] = [
393                        'text' => $encodedValue,
394                        ':id' => $this->id,
395                    ];
396                }
397            } else {
398                $params[] = [
399                    'value' => WebDriverKeys::encode($value),
400                    ':id' => $this->id,
401                ];
402            }
403        } else {
404            if ($this->isW3cCompliant) {
405                try {
406                    // Attempt to upload the file to the remote browser.
407                    // This is so far non-W3C compliant method, so it may fail - if so, we just ignore the exception.
408                    // @see https://github.com/w3c/webdriver/issues/1355
409                    $fileName = $this->upload($local_file);
410                } catch (WebDriverException $e) {
411                    $fileName = $local_file;
412                }
413
414                $params[] = [
415                    'text' => $fileName,
416                    ':id' => $this->id,
417                ];
418            } else {
419                $params[] = [
420                    'value' => WebDriverKeys::encode($this->upload($local_file)),
421                    ':id' => $this->id,
422                ];
423            }
424        }
425
426        foreach ($params as $param) {
427            $this->executor->execute(DriverCommand::SEND_KEYS_TO_ELEMENT, $param);
428        }
429
430        return $this;
431    }
432
433    /**
434     * Set the fileDetector in order to let the RemoteWebElement to know that you are going to upload a file.
435     *
436     * Basically, if you want WebDriver trying to send a file, set the fileDetector
437     * to be LocalFileDetector. Otherwise, keep it UselessFileDetector.
438     *
439     *   eg. `$element->setFileDetector(new LocalFileDetector);`
440     *
441     * @param FileDetector $detector
442     * @return RemoteWebElement
443     * @see FileDetector
444     * @see LocalFileDetector
445     * @see UselessFileDetector
446     */
447    public function setFileDetector(FileDetector $detector)
448    {
449        $this->fileDetector = $detector;
450
451        return $this;
452    }
453
454    /**
455     * If this current element is a form, or an element within a form, then this will be submitted to the remote server.
456     *
457     * @return RemoteWebElement The current instance.
458     */
459    public function submit()
460    {
461        if ($this->isW3cCompliant) {
462            // Submit method cannot be called directly in case an input of this form is named "submit".
463            // We use this polyfill to trigger 'submit' event using form.dispatchEvent().
464            $submitPolyfill = $script = <<<HTXT
465                var form = arguments[0];
466                while (form.nodeName !== "FORM" && form.parentNode) { // find the parent form of this element
467                    form = form.parentNode;
468                }
469                if (!form) {
470                    throw Error('Unable to find containing form element');
471                }
472                var event = new Event('submit', {bubbles: true, cancelable: true});
473                if (form.dispatchEvent(event)) {
474                    HTMLFormElement.prototype.submit.call(form);
475                }
476HTXT;
477            $this->executor->execute(DriverCommand::EXECUTE_SCRIPT, [
478                'script' => $submitPolyfill,
479                'args' => [[JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $this->id]],
480            ]);
481
482            return $this;
483        }
484
485        $this->executor->execute(
486            DriverCommand::SUBMIT_ELEMENT,
487            [':id' => $this->id]
488        );
489
490        return $this;
491    }
492
493    /**
494     * Get the opaque ID of the element.
495     *
496     * @return string The opaque ID.
497     */
498    public function getID()
499    {
500        return $this->id;
501    }
502
503    /**
504     * Take a screenshot of a specific element.
505     *
506     * @param string $save_as The path of the screenshot to be saved.
507     * @return string The screenshot in PNG format.
508     */
509    public function takeElementScreenshot($save_as = null)
510    {
511        $screenshot = base64_decode(
512            $this->executor->execute(
513                DriverCommand::TAKE_ELEMENT_SCREENSHOT,
514                [':id' => $this->id]
515            ),
516            true
517        );
518
519        if ($save_as !== null) {
520            $directoryPath = dirname($save_as);
521            if (!file_exists($directoryPath)) {
522                mkdir($directoryPath, 0777, true);
523            }
524
525            file_put_contents($save_as, $screenshot);
526        }
527
528        return $screenshot;
529    }
530
531    /**
532     * Test if two elements IDs refer to the same DOM element.
533     *
534     * @param WebDriverElement $other
535     * @return bool
536     */
537    public function equals(WebDriverElement $other)
538    {
539        if ($this->isW3cCompliant) {
540            return $this->getID() === $other->getID();
541        }
542
543        return $this->executor->execute(DriverCommand::ELEMENT_EQUALS, [
544            ':id' => $this->id,
545            ':other' => $other->getID(),
546        ]);
547    }
548
549    /**
550     * Attempt to click on a child level element.
551     *
552     * This provides a workaround for geckodriver bug 653 whereby a link whose first element is a block-level element
553     * throws an ElementNotInteractableException could not scroll into view exception.
554     *
555     * The workaround provided here attempts to click on a child node of the element.
556     * In case the first child is hidden, other elements are processed until we run out of elements.
557     *
558     * @param ElementNotInteractableException $originalException The exception to throw if unable to click on any child
559     * @see https://github.com/mozilla/geckodriver/issues/653
560     * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1374283
561     */
562    protected function clickChildElement(ElementNotInteractableException $originalException)
563    {
564        $children = $this->findElements(WebDriverBy::xpath('./*'));
565        foreach ($children as $child) {
566            try {
567                // Note: This does not use $child->click() as this would cause recursion into all children.
568                // Where the element is hidden, all children will also be hidden.
569                $this->executor->execute(
570                    DriverCommand::CLICK_ELEMENT,
571                    [':id' => $child->id]
572                );
573
574                return;
575            } catch (ElementNotInteractableException $e) {
576                // Ignore the ElementNotInteractableException exception on this node. Try the next child instead.
577            }
578        }
579
580        throw $originalException;
581    }
582
583    /**
584     * Return the WebDriverElement with $id
585     *
586     * @param string $id
587     *
588     * @return static
589     */
590    protected function newElement($id)
591    {
592        return new static($this->executor, $id, $this->isW3cCompliant);
593    }
594
595    /**
596     * Upload a local file to the server
597     *
598     * @param string $local_file
599     *
600     * @throws WebDriverException
601     * @return string The remote path of the file.
602     */
603    protected function upload($local_file)
604    {
605        if (!is_file($local_file)) {
606            throw new WebDriverException('You may only upload files: ' . $local_file);
607        }
608
609        $temp_zip_path = $this->createTemporaryZipArchive($local_file);
610
611        $remote_path = $this->executor->execute(
612            DriverCommand::UPLOAD_FILE,
613            ['file' => base64_encode(file_get_contents($temp_zip_path))]
614        );
615
616        unlink($temp_zip_path);
617
618        return $remote_path;
619    }
620
621    /**
622     * @param string $fileToZip
623     * @return string
624     */
625    protected function createTemporaryZipArchive($fileToZip)
626    {
627        // Create a temporary file in the system temp directory.
628        // Intentionally do not use `tempnam()`, as it creates empty file which zip extension may not handle.
629        $tempZipPath = sys_get_temp_dir() . '/' . uniqid('WebDriverZip', false);
630
631        $zip = new ZipArchive();
632        if (($errorCode = $zip->open($tempZipPath, ZipArchive::CREATE)) !== true) {
633            throw new WebDriverException(sprintf('Error creating zip archive: %s', $errorCode));
634        }
635
636        $info = pathinfo($fileToZip);
637        $file_name = $info['basename'];
638        $zip->addFile($fileToZip, $file_name);
639        $zip->close();
640
641        return $tempZipPath;
642    }
643}
644