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