`
330 | * * `['css' => 'input[type=input][value=foo]']` matches `
`
331 | * * `['xpath' => "//input[@type='submit'][contains(@value, 'foo')]"]` matches `
`
332 | * * `['link' => 'Click here']` matches `
Click here`
333 | * * `['class' => 'foo']` matches `
`
334 | *
335 | * Writing good locators can be tricky.
336 | * The Mozilla team has written an excellent guide titled [Writing reliable locators for Selenium and WebDriver tests](https://blog.mozilla.org/webqa/2013/09/26/writing-reliable-locators-for-selenium-and-webdriver-tests/).
337 | *
338 | * If you prefer, you may also pass a string for the locator. This is called a "fuzzy" locator.
339 | * In this case, Codeception uses a a variety of heuristics (depending on the exact method called) to determine what element you're referring to.
340 | * For example, here's the heuristic used for the `submitForm` method:
341 | *
342 | * 1. Does the locator look like an ID selector (e.g. "#foo")? If so, try to find a form matching that ID.
343 | * 2. If nothing found, check if locator looks like a CSS selector. If so, run it.
344 | * 3. If nothing found, check if locator looks like an XPath expression. If so, run it.
345 | * 4. Throw an `ElementNotFound` exception.
346 | *
347 | * Be warned that fuzzy locators can be significantly slower than strict locators.
348 | * Especially if you use Selenium WebDriver with `wait` (aka implicit wait) option.
349 | * In the example above if you set `wait` to 5 seconds and use XPath string as fuzzy locator,
350 | * `submitForm` method will wait for 5 seconds at each step.
351 | * That means 5 seconds finding the form by ID, another 5 seconds finding by CSS
352 | * until it finally tries to find the form by XPath).
353 | * If speed is a concern, it's recommended you stick with explicitly specifying the locator type via the array syntax.
354 | *
355 | * ### Get Scenario Metadata
356 | *
357 | * You can inject `\Codeception\Scenario` into your test to get information about the current configuration:
358 | * ```php
359 | * use Codeception\Scenario;
360 | *
361 | * public function myTest(AcceptanceTester $I, Scenario $scenario)
362 | * {
363 | * if ('firefox' === $scenario->current('browser')) {
364 | * // ...
365 | * }
366 | * }
367 | * ```
368 | * See [Get Scenario Metadata](https://codeception.com/docs/07-AdvancedUsage#Get-Scenario-Metadata) for more information on `$scenario`.
369 | *
370 | * ## Public Properties
371 | *
372 | * * `webDriver` - instance of `\Facebook\WebDriver\Remote\RemoteWebDriver`. Can be accessed from Helper classes for complex WebDriver interactions.
373 | *
374 | * ```php
375 | * // inside Helper class
376 | * $this->getModule('WebDriver')->webDriver->getKeyboard()->sendKeys('hello, webdriver');
377 | * ```
378 | *
379 | */
380 | class WebDriver extends CodeceptionModule implements
381 | WebInterface,
382 | RemoteInterface,
383 | MultiSessionInterface,
384 | SessionSnapshot,
385 | ScreenshotSaver,
386 | PageSourceSaver,
387 | ElementLocator,
388 | ConflictsWithModule,
389 | RequiresPackage
390 | {
391 | /**
392 | * @var string[]
393 | */
394 | protected array $requiredFields = ['browser', 'url'];
395 |
396 | protected array $config = [
397 | 'protocol' => 'http',
398 | 'host' => '127.0.0.1',
399 | 'port' => '4444',
400 | 'path' => '/wd/hub',
401 | 'start' => true,
402 | 'restart' => false,
403 | 'wait' => 0,
404 | 'clear_cookies' => true,
405 | 'window_size' => false,
406 | 'capabilities' => [],
407 | 'connection_timeout' => null,
408 | 'request_timeout' => null,
409 | 'pageload_timeout' => null,
410 | 'http_proxy' => null,
411 | 'http_proxy_port' => null,
412 | 'ssl_proxy' => null,
413 | 'ssl_proxy_port' => null,
414 | 'debug_log_entries' => 0,
415 | 'log_js_errors' => false,
416 | 'webdriver_proxy' => null,
417 | 'webdriver_proxy_port' => null,
418 | ];
419 |
420 | protected ?string $wdHost = null;
421 |
422 | /**
423 | * @var mixed
424 | */
425 | protected $capabilities;
426 |
427 | /**
428 | * @var float|int|null
429 | */
430 | protected $connectionTimeoutInMs;
431 |
432 | /**
433 | * @var float|int|null
434 | */
435 | protected $requestTimeoutInMs;
436 |
437 | protected array $sessions = [];
438 |
439 | protected array $sessionSnapshots = [];
440 |
441 | /**
442 | * @var mixed
443 | */
444 | protected $webdriverProxy;
445 |
446 | /**
447 | * @var mixed
448 | */
449 | protected $webdriverProxyPort;
450 |
451 | public ?RemoteWebDriver $webDriver = null;
452 |
453 | protected ?WebDriverSearchContext $baseElement = null;
454 |
455 | public function _requires(): array
456 | {
457 | return [RemoteWebDriver::class => '"php-webdriver/webdriver": "^1.0.1"'];
458 | }
459 |
460 | /**
461 | * @throws ModuleException
462 | */
463 | protected function getBaseElement(): WebDriverSearchContext
464 | {
465 | if (!$this->baseElement) {
466 | throw new ModuleException(
467 | $this,
468 | "Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it"
469 | );
470 | }
471 |
472 | return $this->baseElement;
473 | }
474 |
475 | public function _initialize()
476 | {
477 | $this->wdHost = sprintf(
478 | '%s://%s:%s%s',
479 | $this->config['protocol'],
480 | $this->config['host'],
481 | $this->config['port'],
482 | $this->config['path']
483 | );
484 | $this->capabilities = $this->config['capabilities'];
485 | $this->capabilities[WebDriverCapabilityType::BROWSER_NAME] = $this->config['browser'];
486 | if ($proxy = $this->getProxy()) {
487 | $this->capabilities[WebDriverCapabilityType::PROXY] = $proxy;
488 | }
489 |
490 | $this->connectionTimeoutInMs = $this->config['connection_timeout'] * 1000;
491 | $this->requestTimeoutInMs = $this->config['request_timeout'] * 1000;
492 | $this->webdriverProxy = $this->config['webdriver_proxy'];
493 | $this->webdriverProxyPort = $this->config['webdriver_proxy_port'];
494 | $this->loadFirefoxProfile();
495 | }
496 |
497 | /**
498 | * Change capabilities of WebDriver. Should be executed before starting a new browser session.
499 | * This method expects a function to be passed which returns array or [WebDriver Desired Capabilities](https://github.com/php-webdriver/php-webdriver/blob/main/lib/Remote/DesiredCapabilities.php) object.
500 | * Additional [Chrome options](https://github.com/php-webdriver/php-webdriver/wiki/ChromeOptions) (like adding extensions) can be passed as well.
501 | *
502 | * ```php
503 | * getModule('WebDriver')->_capabilities(function($currentCapabilities) {
507 | * // or new \Facebook\WebDriver\Remote\DesiredCapabilities();
508 | * return \Facebook\WebDriver\Remote\DesiredCapabilities::firefox();
509 | * });
510 | * }
511 | * ```
512 | *
513 | * to make this work load `\Helper\Acceptance` before `WebDriver` in `acceptance.suite.yml`:
514 | *
515 | * ```yaml
516 | * modules:
517 | * enabled:
518 | * - \Helper\Acceptance
519 | * - WebDriver
520 | * ```
521 | *
522 | * For instance, [**BrowserStack** cloud service](https://www.browserstack.com/automate/capabilities) may require a test name to be set in capabilities.
523 | * This is how it can be done via `_capabilities` method from `Helper\Acceptance`:
524 | *
525 | * ```php
526 | * getMetadata()->getName();
531 | * $this->getModule('WebDriver')->_capabilities(function($currentCapabilities) use ($name) {
532 | * $currentCapabilities['name'] = $name;
533 | * return $currentCapabilities;
534 | * });
535 | * }
536 | * ```
537 | * In this case, please ensure that `\Helper\Acceptance` is loaded before WebDriver so new capabilities could be applied.
538 | *
539 | * @api
540 | */
541 | public function _capabilities(Closure $capabilityFunction): void
542 | {
543 | $this->capabilities = $capabilityFunction($this->capabilities);
544 | }
545 |
546 | public function _conflicts(): string
547 | {
548 | return WebInterface::class;
549 | }
550 |
551 | public function _before(TestInterface $test)
552 | {
553 | if ($this->webDriver === null && $this->config['start']) {
554 | $this->_initializeSession();
555 | }
556 |
557 | $this->setBaseElement();
558 |
559 | $test->getMetadata()->setCurrent(
560 | [
561 | 'browser' => $this->webDriver->getCapabilities()->getBrowserName(),
562 | 'capabilities' => $this->webDriver->getCapabilities()->toArray(),
563 | ]
564 | );
565 | }
566 |
567 | /**
568 | * Restarts a web browser.
569 | * Can be used with `_reconfigure` to open browser with different configuration
570 | *
571 | * ```php
572 | * getModule('WebDriver')->_restart(); // just restart
575 | * $this->getModule('WebDriver')->_restart(['browser' => $browser]); // reconfigure + restart
576 | * ```
577 | *
578 | * @api
579 | */
580 | public function _restart(array $config = []): void
581 | {
582 | $this->webDriver->quit();
583 | if (!empty($config)) {
584 | $this->_reconfigure($config);
585 | }
586 |
587 | $this->_initializeSession();
588 | }
589 |
590 | protected function onReconfigure()
591 | {
592 | $this->_initialize();
593 | }
594 |
595 | protected function loadFirefoxProfile(): void
596 | {
597 | if (!array_key_exists('firefox_profile', $this->config['capabilities'])) {
598 | return;
599 | }
600 |
601 | $firefox_profile = $this->config['capabilities']['firefox_profile'];
602 | if (!file_exists($firefox_profile)) {
603 | throw new ModuleConfigException(
604 | __CLASS__,
605 | "Firefox profile does not exist under given path " . $firefox_profile
606 | );
607 | }
608 |
609 | // Set firefox profile as capability
610 | $this->capabilities['firefox_profile'] = file_get_contents($firefox_profile);
611 | }
612 |
613 | protected function initialWindowSize(): void
614 | {
615 | if ($this->config['window_size'] == 'maximize') {
616 | $this->maximizeWindow();
617 | return;
618 | }
619 |
620 | $size = explode('x', (string) $this->config['window_size']);
621 | if (count($size) == 2) {
622 | $this->resizeWindow((int) $size[0], (int) $size[1]);
623 | }
624 | }
625 |
626 | public function _after(TestInterface $test)
627 | {
628 | if ($this->config['restart']) {
629 | $this->stopAllSessions();
630 | return;
631 | }
632 |
633 | if ($this->config['clear_cookies'] && $this->webDriver !== null) {
634 | try {
635 | $this->webDriver->manage()->deleteAllCookies();
636 | } catch (Exception $exception) {
637 | // may cause fatal errors when not handled
638 | $this->debug("Error, can't clean cookies after a test: " . $exception->getMessage());
639 | }
640 | }
641 | }
642 |
643 | public function _failed(TestInterface $test, $fail)
644 | {
645 | if (!$test instanceof SelfDescribing) {
646 | // this exception should never been throw because all existing test types implement SelfDescribing
647 | throw new InvalidArgumentException('Test class does not implement SelfDescribing interface');
648 | }
649 | $this->debugWebDriverLogs($test);
650 | $filename = preg_replace('#[^a-zA-Z0-9\x80-\xff]#', '.', Descriptor::getTestSignatureUnique($test));
651 | $outputDir = codecept_output_dir();
652 | $this->_saveScreenshot($report = $outputDir . mb_strcut($filename, 0, 245, 'utf-8') . '.fail.png');
653 | $test->getMetadata()->addReport('png', $report);
654 | $this->_savePageSource($report = $outputDir . mb_strcut($filename, 0, 244, 'utf-8') . '.fail.html');
655 | $test->getMetadata()->addReport('html', $report);
656 | $this->debug("Screenshot and page source were saved into '{$outputDir}' dir");
657 | }
658 |
659 | /**
660 | * Print out latest Selenium Logs in debug mode
661 | */
662 | public function debugWebDriverLogs(?TestInterface $test = null): void
663 | {
664 | if ($this->webDriver === null) {
665 | $this->debug('WebDriver::debugWebDriverLogs method has been called when webDriver is not set');
666 | return;
667 | }
668 |
669 | // don't show logs if log entries not set
670 | if (!$this->config['debug_log_entries']) {
671 | return;
672 | }
673 |
674 | try {
675 | // Dump out latest Selenium logs
676 | $logs = $this->webDriver->manage()->getAvailableLogTypes();
677 | foreach ($logs as $logType) {
678 | $logEntries = array_slice(
679 | $this->webDriver->manage()->getLog($logType),
680 | -$this->config['debug_log_entries']
681 | );
682 |
683 | if (empty($logEntries)) {
684 | $this->debugSection("Selenium {$logType} Logs", " EMPTY ");
685 | continue;
686 | }
687 |
688 | $this->debugSection("Selenium {$logType} Logs", "\n" . $this->formatLogEntries($logEntries));
689 |
690 | if (
691 | $logType === 'browser' && $this->config['log_js_errors']
692 | && ($test instanceof ScenarioDriven)
693 | ) {
694 | $this->logJSErrors($test, $logEntries);
695 | }
696 | }
697 | } catch (Exception $e) {
698 | $this->debug('Unable to retrieve Selenium logs : ' . $e->getMessage());
699 | }
700 | }
701 |
702 | /**
703 | * Turns an array of log entries into a human-readable string.
704 | * Each log entry is an array with the keys "timestamp", "level", and "message".
705 | * See https://code.google.com/p/selenium/wiki/JsonWireProtocol#Log_Entry_JSON_Object
706 | */
707 | protected function formatLogEntries(array $logEntries): string
708 | {
709 | $formattedLogs = '';
710 |
711 | foreach ($logEntries as $logEntry) {
712 | // Timestamp is in milliseconds, but date() requires seconds.
713 | $time = date('H:i:s', intval($logEntry['timestamp'] / 1000)) .
714 | // Append the milliseconds to the end of the time string
715 | '.' . ($logEntry['timestamp'] % 1000);
716 | $formattedLogs .= "{$time} {$logEntry['level']} - {$logEntry['message']}\n";
717 | }
718 |
719 | return $formattedLogs;
720 | }
721 |
722 | /**
723 | * Logs JavaScript errors as comments.
724 | */
725 | protected function logJSErrors(ScenarioDriven $test, array $browserLogEntries): void
726 | {
727 | foreach ($browserLogEntries as $logEntry) {
728 | if (
729 | isset($logEntry['level'])
730 | && isset($logEntry['message'])
731 | && $this->isJSError($logEntry['level'], $logEntry['message'])
732 | ) {
733 | // Timestamp is in milliseconds, but date() requires seconds.
734 | $time = date('H:i:s', intval($logEntry['timestamp'] / 1000)) .
735 | // Append the milliseconds to the end of the time string
736 | '.' . ($logEntry['timestamp'] % 1000);
737 | $test->getScenario()->comment("{$time} {$logEntry['level']} - {$logEntry['message']}");
738 | }
739 | }
740 | }
741 |
742 | /**
743 | * Determines if the log entry is an error.
744 | * The decision is made depending on browser and log-level.
745 | */
746 | protected function isJSError(string $logEntryLevel, string $message): bool
747 | {
748 | return
749 | (
750 | ($this->isPhantom() && $logEntryLevel != 'INFO') // phantomjs logs errors as "WARNING"
751 | || $logEntryLevel === 'SEVERE' // other browsers log errors as "SEVERE"
752 | )
753 | && strpos($message, 'ERR_PROXY_CONNECTION_FAILED') === false; // ignore blackhole proxy
754 | }
755 |
756 | public function _afterSuite()
757 | {
758 | // this is just to make sure webDriver is cleared after suite
759 | $this->stopAllSessions();
760 | }
761 |
762 | protected function stopAllSessions(): void
763 | {
764 | foreach ($this->sessions as $session) {
765 | $this->_closeSession($session);
766 | }
767 |
768 | $this->webDriver = null;
769 | $this->baseElement = null;
770 | }
771 |
772 | public function amOnSubdomain(string $subdomain): void
773 | {
774 | $url = $this->config['url'];
775 | $url = preg_replace('#(https?://)(.*\.)(.*\.)#', "$1$3", $url); // removing current subdomain
776 | $url = preg_replace('#(https?://)(.*)#', sprintf('$1%s.$2', $subdomain), $url); // inserting new
777 | $this->_reconfigure(['url' => $url]);
778 | }
779 |
780 | /**
781 | * Returns URL of a host.
782 | *
783 | * @api
784 | * @return mixed
785 | * @throws ModuleConfigException
786 | */
787 | public function _getUrl()
788 | {
789 | if (!isset($this->config['url'])) {
790 | throw new ModuleConfigException(
791 | __CLASS__,
792 | "Module connection failure. The URL for client can't bre retrieved"
793 | );
794 | }
795 |
796 | return $this->config['url'];
797 | }
798 |
799 | protected function getProxy(): ?array
800 | {
801 | $proxyConfig = [];
802 | if ($this->config['http_proxy']) {
803 | $proxyConfig['httpProxy'] = $this->config['http_proxy'];
804 | if ($this->config['http_proxy_port']) {
805 | $proxyConfig['httpProxy'] .= ':' . $this->config['http_proxy_port'];
806 | }
807 | }
808 |
809 | if ($this->config['ssl_proxy']) {
810 | $proxyConfig['sslProxy'] = $this->config['ssl_proxy'];
811 | if ($this->config['ssl_proxy_port']) {
812 | $proxyConfig['sslProxy'] .= ':' . $this->config['ssl_proxy_port'];
813 | }
814 | }
815 |
816 | if (!empty($proxyConfig)) {
817 | $proxyConfig['proxyType'] = 'manual';
818 | return $proxyConfig;
819 | }
820 |
821 | return null;
822 | }
823 |
824 | /**
825 | * Uri of currently opened page.
826 | * @api
827 | * @throws ModuleException
828 | */
829 | public function _getCurrentUri(): string
830 | {
831 | $url = $this->webDriver->getCurrentURL();
832 | if ($url == 'about:blank' || strpos($url, 'data:') === 0) {
833 | throw new ModuleException($this, 'Current url is blank, no page was opened');
834 | }
835 |
836 | return Uri::retrieveUri($url);
837 | }
838 |
839 | public function _saveScreenshot(string $filename)
840 | {
841 | if ($this->webDriver === null) {
842 | $this->debug('WebDriver::_saveScreenshot method has been called when webDriver is not set');
843 | return;
844 | }
845 |
846 | try {
847 | $this->webDriver->takeScreenshot($filename);
848 | } catch (Exception $e) {
849 | $this->debug('Unable to retrieve screenshot from Selenium : ' . $e->getMessage());
850 | return;
851 | }
852 | }
853 |
854 | /**
855 | * @param string|array|WebDriverBy $selector
856 | */
857 | public function _saveElementScreenshot($selector, string $filename): void
858 | {
859 | if ($this->webDriver === null) {
860 | $this->debug('WebDriver::_saveElementScreenshot method has been called when webDriver is not set');
861 | return;
862 | }
863 |
864 | try {
865 | $this->matchFirstOrFail($this->webDriver, $selector)->takeElementScreenshot($filename);
866 | } catch (Exception $e) {
867 | $this->debug('Unable to retrieve element screenshot from Selenium : ' . $e->getMessage());
868 | return;
869 | }
870 | }
871 |
872 | public function _findElements($locator): array
873 | {
874 | return $this->match($this->webDriver, $locator);
875 | }
876 |
877 | /**
878 | * Saves HTML source of a page to a file
879 | */
880 | public function _savePageSource(string $filename): void
881 | {
882 | if ($this->webDriver === null) {
883 | $this->debug('WebDriver::_savePageSource method has been called when webDriver is not set');
884 | return;
885 | }
886 |
887 | try {
888 | file_put_contents($filename, $this->webDriver->getPageSource());
889 | } catch (Exception $e) {
890 | $this->debug('Unable to retrieve source page from Selenium : ' . $e->getMessage());
891 | }
892 | }
893 |
894 | /**
895 | * Takes a screenshot of the current window and saves it to `tests/_output/debug`.
896 | *
897 | * ``` php
898 | * amOnPage('/user/edit');
900 | * $I->makeScreenshot('edit_page');
901 | * // saved to: tests/_output/debug/edit_page.png
902 | * $I->makeScreenshot();
903 | * // saved to: tests/_output/debug/2017-05-26_14-24-11_4b3403665fea6.png
904 | * ```
905 | */
906 | public function makeScreenshot(?string $name = null): void
907 | {
908 | if (empty($name)) {
909 | $name = uniqid(date("Y-m-d_H-i-s_"));
910 | }
911 |
912 | $debugDir = codecept_log_dir() . 'debug';
913 | if (!is_dir($debugDir)) {
914 | mkdir($debugDir);
915 | }
916 |
917 | $screenName = $debugDir . DIRECTORY_SEPARATOR . $name . '.png';
918 | $this->_saveScreenshot($screenName);
919 | $this->debugSection('Screenshot Saved', "file://{$screenName}");
920 | }
921 |
922 | /**
923 | * Takes a screenshot of an element of the current window and saves it to `tests/_output/debug`.
924 | *
925 | * ``` php
926 | * amOnPage('/user/edit');
928 | * $I->makeElementScreenshot('#dialog', 'edit_page');
929 | * // saved to: tests/_output/debug/edit_page.png
930 | * $I->makeElementScreenshot('#dialog');
931 | * // saved to: tests/_output/debug/2017-05-26_14-24-11_4b3403665fea6.png
932 | * ```
933 | *
934 | * @param WebDriverBy|array $selector
935 | */
936 | public function makeElementScreenshot($selector, ?string $name = null): void
937 | {
938 | if (empty($name)) {
939 | $name = uniqid(date("Y-m-d_H-i-s_"));
940 | }
941 |
942 | $debugDir = codecept_log_dir() . 'debug';
943 | if (!is_dir($debugDir)) {
944 | mkdir($debugDir);
945 | }
946 |
947 | $screenName = $debugDir . DIRECTORY_SEPARATOR . $name . '.png';
948 | $this->_saveElementScreenshot($selector, $screenName);
949 | $this->debugSection('Screenshot Saved', "file://{$screenName}");
950 | }
951 |
952 | public function makeHtmlSnapshot(?string $name = null): void
953 | {
954 | if (empty($name)) {
955 | $name = uniqid(date("Y-m-d_H-i-s_"));
956 | }
957 |
958 | $debugDir = codecept_output_dir() . 'debug';
959 | if (!is_dir($debugDir)) {
960 | mkdir($debugDir);
961 | }
962 |
963 | $fileName = $debugDir . DIRECTORY_SEPARATOR . $name . '.html';
964 |
965 | $this->_savePageSource($fileName);
966 | $this->debugSection('Snapshot Saved', "file://{$fileName}");
967 | }
968 |
969 |
970 |
971 | /**
972 | * Resize the current window.
973 | *
974 | * ``` php
975 | * resizeWindow(800, 600);
977 | *
978 | * ```
979 | */
980 | public function resizeWindow(int $width, int $height): void
981 | {
982 | $this->webDriver->manage()->window()->setSize(new WebDriverDimension($width, $height));
983 | }
984 |
985 | private function debugCookies(): void
986 | {
987 | $result = [];
988 | $cookies = $this->webDriver->manage()->getCookies();
989 | foreach ($cookies as $cookie) {
990 | $result[] = $cookie->toArray();
991 | }
992 |
993 | $this->debugSection('Cookies', json_encode($result, JSON_THROW_ON_ERROR));
994 | }
995 |
996 | public function seeCookie($cookie, array $params = [], bool $showDebug = true): void
997 | {
998 | $cookies = $this->filterCookies($this->webDriver->manage()->getCookies(), $params);
999 | $cookies = array_map(
1000 | fn($c) => $c['name'],
1001 | $cookies
1002 | );
1003 | if ($showDebug) {
1004 | $this->debugCookies();
1005 | }
1006 | $this->assertContains($cookie, $cookies);
1007 | }
1008 |
1009 | public function dontSeeCookie($cookie, array $params = [], bool $showDebug = true): void
1010 | {
1011 | $cookies = $this->filterCookies($this->webDriver->manage()->getCookies(), $params);
1012 | $cookies = array_map(
1013 | fn($c) => $c['name'],
1014 | $cookies
1015 | );
1016 | if ($showDebug) {
1017 | $this->debugCookies();
1018 | }
1019 | $this->assertNotContains($cookie, $cookies);
1020 | }
1021 |
1022 | public function setCookie($name, $value, array $params = [], $showDebug = true): void
1023 | {
1024 | $params['name'] = $name;
1025 | $params['value'] = $value;
1026 | if (isset($params['expires'])) { // PhpBrowser compatibility
1027 | $params['expiry'] = $params['expires'];
1028 | }
1029 |
1030 | // #5401 Supply defaults, otherwise chromedriver 2.46 complains.
1031 | $defaults = [
1032 | 'path' => '/',
1033 | 'expiry' => time() + 86400,
1034 | 'secure' => false,
1035 | 'httpOnly' => false,
1036 | ];
1037 | foreach ($defaults as $key => $default) {
1038 | if (empty($params[$key])) {
1039 | $params[$key] = $default;
1040 | }
1041 | }
1042 |
1043 | $this->webDriver->manage()->addCookie($params);
1044 | if ($showDebug) {
1045 | $this->debugCookies();
1046 | }
1047 | }
1048 |
1049 | public function resetCookie($cookie, array $params = [], bool $showDebug = true): void
1050 | {
1051 | $this->webDriver->manage()->deleteCookieNamed($cookie);
1052 | if ($showDebug) {
1053 | $this->debugCookies();
1054 | }
1055 | }
1056 |
1057 | public function grabCookie($cookie, array $params = []): mixed
1058 | {
1059 | $params['name'] = $cookie;
1060 | $cookies = $this->filterCookies($this->webDriver->manage()->getCookies(), $params);
1061 | if (empty($cookies)) {
1062 | return null;
1063 | }
1064 |
1065 | $cookie = reset($cookies);
1066 | return $cookie['value'];
1067 | }
1068 |
1069 | /**
1070 | * Grabs current page source code.
1071 | *
1072 | * @throws ModuleException if no page was opened.
1073 | * @return string Current page source code.
1074 | */
1075 | public function grabPageSource(): string
1076 | {
1077 | // Make sure that some page was opened.
1078 | $this->_getCurrentUri();
1079 |
1080 | return $this->webDriver->getPageSource();
1081 | }
1082 |
1083 | /**
1084 | * @param Cookie[] $cookies
1085 | * @param array $params
1086 | * @return Cookie[]
1087 | */
1088 | protected function filterCookies(array $cookies, array $params = []): array
1089 | {
1090 | foreach (['domain', 'path', 'name'] as $filter) {
1091 | if (!isset($params[$filter])) {
1092 | continue;
1093 | }
1094 |
1095 | $cookies = array_filter(
1096 | $cookies,
1097 | fn($item): bool => $item[$filter] == $params[$filter]
1098 | );
1099 | }
1100 |
1101 | return $cookies;
1102 | }
1103 |
1104 | public function amOnUrl($url): void
1105 | {
1106 | $host = Uri::retrieveHost($url);
1107 | $this->_reconfigure(['url' => $host]);
1108 | $this->debugSection('Host', $host);
1109 | $this->webDriver->get($url);
1110 | }
1111 |
1112 | public function amOnPage($page): void
1113 | {
1114 | $url = Uri::appendPath($this->config['url'], $page);
1115 | $this->debugSection('GET', $url);
1116 | $this->webDriver->get($url);
1117 | }
1118 |
1119 | public function see($text, $selector = null): void
1120 | {
1121 | if (!$selector) {
1122 | $this->assertPageContains($text);
1123 | return;
1124 | }
1125 |
1126 | $this->enableImplicitWait();
1127 | $nodes = $this->matchVisible($selector);
1128 | $this->disableImplicitWait();
1129 | $this->assertNodesContain($text, $nodes, $selector);
1130 | }
1131 |
1132 | public function dontSee($text, $selector = null): void
1133 | {
1134 | if (!$selector) {
1135 | $this->assertPageNotContains($text);
1136 | } else {
1137 | $nodes = $this->matchVisible($selector);
1138 | $this->assertNodesNotContain($text, $nodes, $selector);
1139 | }
1140 | }
1141 |
1142 | public function seeInSource($raw): void
1143 | {
1144 | $this->assertPageSourceContains($raw);
1145 | }
1146 |
1147 | public function dontSeeInSource($raw): void
1148 | {
1149 | $this->assertPageSourceNotContains($raw);
1150 | }
1151 |
1152 | /**
1153 | * Checks that the page source contains the given string.
1154 | *
1155 | * ```php
1156 | * seeInPageSource('assertThat(
1163 | $this->webDriver->getPageSource(),
1164 | new PageConstraint($text, $this->_getCurrentUri())
1165 | );
1166 | }
1167 |
1168 | /**
1169 | * Checks that the page source doesn't contain the given string.
1170 | */
1171 | public function dontSeeInPageSource(string $text): void
1172 | {
1173 | $this->assertThatItsNot(
1174 | $this->webDriver->getPageSource(),
1175 | new PageConstraint($text, $this->_getCurrentUri())
1176 | );
1177 | }
1178 |
1179 | public function click($link, $context = null): void
1180 | {
1181 | $page = $this->webDriver;
1182 | if ($context) {
1183 | $page = $this->matchFirstOrFail($this->webDriver, $context);
1184 | }
1185 |
1186 | $el = $this->_findClickable($page, $link);
1187 | if ($el === null) { // check one more time if this was a CSS selector we didn't match
1188 | try {
1189 | $els = $this->match($page, $link);
1190 | } catch (MalformedLocatorException $exception) {
1191 | throw new ElementNotFound(
1192 | "name={$link}",
1193 | "'{$link}' is invalid CSS and XPath selector and Link or Button"
1194 | );
1195 | }
1196 |
1197 | $el = reset($els);
1198 | }
1199 |
1200 | if (!$el) {
1201 | throw new ElementNotFound($link, 'Link or Button or CSS or XPath');
1202 | }
1203 |
1204 | $el->click();
1205 | }
1206 |
1207 | /**
1208 | * Locates a clickable element.
1209 | *
1210 | * Use it in Helpers or GroupObject or Extension classes:
1211 | *
1212 | * ```php
1213 | * getModule('WebDriver');
1215 | * $page = $module->webDriver;
1216 | *
1217 | * // search a link or button on a page
1218 | * $el = $module->_findClickable($page, 'Click Me');
1219 | *
1220 | * // search a link or button within an element
1221 | * $topBar = $module->_findElements('.top-bar')[0];
1222 | * $el = $module->_findClickable($topBar, 'Click Me');
1223 | *
1224 | * ```
1225 | * @param WebDriverSearchContext $page WebDriver instance or an element to search within
1226 | * @param string|array|WebDriverBy $link A link text or locator to click
1227 | * @api
1228 | */
1229 | public function _findClickable(WebDriverSearchContext $page, $link): ?WebDriverElement
1230 | {
1231 | if (is_array($link) || $link instanceof WebDriverBy) {
1232 | return $this->matchFirstOrFail($page, $link);
1233 | }
1234 |
1235 | // try to match by strict locators, CSS Ids or XPath
1236 | if (Locator::isPrecise($link)) {
1237 | return $this->matchFirstOrFail($page, $link);
1238 | }
1239 |
1240 | $locator = self::xPathLiteral(trim((string) $link));
1241 |
1242 | // narrow
1243 | $xpath = Locator::combine(
1244 | ".//a[normalize-space(.)={$locator}]",
1245 | ".//button[normalize-space(.)={$locator}]",
1246 | ".//a/img[normalize-space(@alt)={$locator}]/ancestor::a",
1247 | ".//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][normalize-space(@value)={$locator}]"
1248 | );
1249 |
1250 | $els = $page->findElements(WebDriverBy::xpath($xpath));
1251 | if (count($els) > 0) {
1252 | return reset($els);
1253 | }
1254 |
1255 | // wide
1256 | $xpath = Locator::combine(
1257 | ".//a[./@href][((contains(normalize-space(string(.)), {$locator})) or contains(./@title, {$locator}) or .//img[contains(./@alt, {$locator})])]",
1258 | ".//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][contains(./@value, {$locator})]",
1259 | ".//input[./@type = 'image'][contains(./@alt, {$locator})]",
1260 | ".//button[contains(normalize-space(string(.)), {$locator})]",
1261 | ".//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][./@name = {$locator} or ./@title = {$locator}]",
1262 | ".//button[./@name = {$locator} or ./@title = {$locator}]"
1263 | );
1264 | $els = $page->findElements(WebDriverBy::xpath($xpath));
1265 | if (count($els) > 0) {
1266 | return reset($els);
1267 | }
1268 |
1269 | return null;
1270 | }
1271 |
1272 | /**
1273 | * @param WebDriverElement|WebDriverBy|array|string $selector
1274 | * @return WebDriverElement[]
1275 | * @throws ElementNotFound
1276 | */
1277 | protected function findFields($selector): array
1278 | {
1279 | if ($selector instanceof WebDriverElement) {
1280 | return [$selector];
1281 | }
1282 |
1283 | if (is_array($selector) || ($selector instanceof WebDriverBy)) {
1284 | $fields = $this->match($this->getBaseElement(), $selector);
1285 |
1286 | if (empty($fields)) {
1287 | throw new ElementNotFound($selector);
1288 | }
1289 |
1290 | return $fields;
1291 | }
1292 |
1293 | $locator = self::xPathLiteral(trim((string) $selector));
1294 | // by text or label
1295 | $xpath = Locator::combine(
1296 | ".//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = {$locator}) or ./@id = //label[contains(normalize-space(string(.)), {$locator})]/@for) or ./@placeholder = {$locator})]",
1297 | ".//label[contains(normalize-space(string(.)), {$locator})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]"
1298 | );
1299 | $fields = $this->getBaseElement()->findElements(WebDriverBy::xpath($xpath));
1300 | if (!empty($fields)) {
1301 | return $fields;
1302 | }
1303 |
1304 | // by name
1305 | $xpath = ".//*[self::input | self::textarea | self::select][@name = {$locator}]";
1306 | $fields = $this->getBaseElement()->findElements(WebDriverBy::xpath($xpath));
1307 | if (!empty($fields)) {
1308 | return $fields;
1309 | }
1310 |
1311 | // try to match by CSS or XPath
1312 | $fields = $this->match($this->getBaseElement(), $selector, false);
1313 | if (!empty($fields)) {
1314 | return $fields;
1315 | }
1316 |
1317 | throw new ElementNotFound($selector, "Field by name, label, CSS or XPath");
1318 | }
1319 |
1320 | /**
1321 | * @param string|array|WebDriverBy|WebDriverElement $selector
1322 | * @throws ElementNotFound
1323 | */
1324 | protected function findField($selector): WebDriverElement
1325 | {
1326 | $arr = $this->findFields($selector);
1327 | return reset($arr);
1328 | }
1329 |
1330 | public function seeLink(string $text, ?string $url = null): void
1331 | {
1332 | $this->enableImplicitWait();
1333 | $nodes = $this->getBaseElement()->findElements(WebDriverBy::partialLinkText($text));
1334 | $this->disableImplicitWait();
1335 | $currentUri = $this->_getCurrentUri();
1336 |
1337 | if (empty($nodes)) {
1338 | $this->fail("No links containing text '{$text}' were found in page {$currentUri}");
1339 | }
1340 |
1341 | if ($url) {
1342 | $nodes = $this->filterNodesByHref($url, $nodes);
1343 | }
1344 |
1345 | $this->assertNotEmpty(
1346 | $nodes,
1347 | "No links containing text '{$text}' and URL '{$url}' were found in page {$currentUri}"
1348 | );
1349 | }
1350 |
1351 | public function dontSeeLink(string $text, string $url = ''): void
1352 | {
1353 | $nodes = $this->getBaseElement()->findElements(WebDriverBy::partialLinkText($text));
1354 | $currentUri = $this->_getCurrentUri();
1355 | if (!$url) {
1356 | $this->assertEmpty($nodes, "Link containing text '{$text}' was found in page {$currentUri}");
1357 | } else {
1358 | $nodes = $this->filterNodesByHref($url, $nodes);
1359 | $this->assertEmpty(
1360 | $nodes,
1361 | "Link containing text '{$text}' and URL '{$url}' was found in page {$currentUri}"
1362 | );
1363 | }
1364 | }
1365 |
1366 | private function filterNodesByHref(string $url, array $nodes): array
1367 | {
1368 | //current uri can be relative, merging it with configured base url gives absolute url
1369 | $absoluteCurrentUrl = Uri::mergeUrls($this->_getUrl(), $this->_getCurrentUri());
1370 | $expectedUrl = Uri::mergeUrls($absoluteCurrentUrl, $url);
1371 | return array_filter(
1372 | $nodes,
1373 | function (WebDriverElement $e) use ($expectedUrl, $absoluteCurrentUrl): bool {
1374 | $elementHref = Uri::mergeUrls($absoluteCurrentUrl, $e->getAttribute('href') ?? '');
1375 | return $elementHref === $expectedUrl;
1376 | }
1377 | );
1378 | }
1379 |
1380 | public function seeInCurrentUrl(string $uri): void
1381 | {
1382 | $this->assertStringContainsString($uri, $this->_getCurrentUri());
1383 | }
1384 |
1385 | public function seeCurrentUrlEquals(string $uri): void
1386 | {
1387 | $this->assertEquals($uri, $this->_getCurrentUri());
1388 | }
1389 |
1390 | public function seeCurrentUrlMatches(string $uri): void
1391 | {
1392 | $this->assertRegExp($uri, $this->_getCurrentUri());
1393 | }
1394 |
1395 | public function dontSeeInCurrentUrl(string $uri): void
1396 | {
1397 | $this->assertStringNotContainsString($uri, $this->_getCurrentUri());
1398 | }
1399 |
1400 | public function dontSeeCurrentUrlEquals(string $uri): void
1401 | {
1402 | $this->assertNotEquals($uri, $this->_getCurrentUri());
1403 | }
1404 |
1405 | public function dontSeeCurrentUrlMatches(string $uri): void
1406 | {
1407 | $this->assertNotRegExp($uri, $this->_getCurrentUri());
1408 | }
1409 |
1410 | public function grabFromCurrentUrl($uri = null): mixed
1411 | {
1412 | if (!$uri) {
1413 | return $this->_getCurrentUri();
1414 | }
1415 |
1416 | $matches = [];
1417 | $res = preg_match($uri, $this->_getCurrentUri(), $matches);
1418 | if (!$res) {
1419 | $this->fail("Couldn't match {$uri} in " . $this->_getCurrentUri());
1420 | }
1421 |
1422 | if (!isset($matches[1])) {
1423 | $this->fail("Nothing to grab. A regex parameter required. Ex: '/user/(\\d+)'");
1424 | }
1425 |
1426 | return $matches[1];
1427 | }
1428 |
1429 | public function seeCheckboxIsChecked($checkbox): void
1430 | {
1431 | $this->assertTrue($this->findField($checkbox)->isSelected());
1432 | }
1433 |
1434 | public function dontSeeCheckboxIsChecked($checkbox): void
1435 | {
1436 | $this->assertFalse($this->findField($checkbox)->isSelected());
1437 | }
1438 |
1439 | public function seeInField($field, $value): void
1440 | {
1441 | $els = $this->findFields($field);
1442 | $this->assert($this->proceedSeeInField($els, $value));
1443 | }
1444 |
1445 | public function dontSeeInField($field, $value): void
1446 | {
1447 | $els = $this->findFields($field);
1448 | $this->assertNot($this->proceedSeeInField($els, $value));
1449 | }
1450 |
1451 | public function seeInFormFields($formSelector, array $params): void
1452 | {
1453 | $this->proceedSeeInFormFields($formSelector, $params, false);
1454 | }
1455 |
1456 | public function dontSeeInFormFields($formSelector, array $params): void
1457 | {
1458 | $this->proceedSeeInFormFields($formSelector, $params, true);
1459 | }
1460 |
1461 | /**
1462 | * @param string|array|WebDriverBy $formSelector
1463 | * @throws ModuleException
1464 | */
1465 | protected function proceedSeeInFormFields($formSelector, array $params, bool $assertNot)
1466 | {
1467 | $form = $this->match($this->getBaseElement(), $formSelector);
1468 | if (empty($form)) {
1469 | throw new ElementNotFound($formSelector, "Form via CSS or XPath");
1470 | }
1471 |
1472 | $form = reset($form);
1473 |
1474 | $els = [];
1475 | foreach ($params as $name => $values) {
1476 | $this->pushFormField($els, $form, $name, $values);
1477 | }
1478 |
1479 | foreach ($els as $arrayElement) {
1480 | [$el, $values] = $arrayElement;
1481 |
1482 | if (!is_array($values)) {
1483 | $values = [$values];
1484 | }
1485 |
1486 | foreach ($values as $value) {
1487 | $ret = $this->proceedSeeInField($el, $value);
1488 | if ($assertNot) {
1489 | $this->assertNot($ret);
1490 | } else {
1491 | $this->assert($ret);
1492 | }
1493 | }
1494 | }
1495 | }
1496 |
1497 | /**
1498 | * Map an array element passed to seeInFormFields to its corresponding WebDriver element,
1499 | * recursing through array values if the field is not found.
1500 | *
1501 | * @param array $els The previously found elements.
1502 | * @param WebDriverElement $form The form in which to search for fields.
1503 | * @param string $name The field's name.
1504 | * @param mixed $values
1505 | */
1506 | protected function pushFormField(array &$els, WebDriverElement $form, string $name, $values): void
1507 | {
1508 | $el = $form->findElements(WebDriverBy::name($name));
1509 |
1510 | if ($el !== []) {
1511 | $els[] = [$el, $values];
1512 | } elseif (is_array($values)) {
1513 | foreach ($values as $key => $value) {
1514 | $this->pushFormField($els, $form, "{$name}[{$key}]", $value);
1515 | }
1516 | } else {
1517 | throw new ElementNotFound($name);
1518 | }
1519 | }
1520 |
1521 | /**
1522 | * @param WebDriverElement[] $elements
1523 | * @param mixed $value
1524 | */
1525 | protected function proceedSeeInField(array $elements, $value): array
1526 | {
1527 | $strField = reset($elements)->getAttribute('name');
1528 | if (reset($elements)->getTagName() === 'select') {
1529 | $el = reset($elements);
1530 | $elements = $el->findElements(WebDriverBy::xpath('.//option'));
1531 | if (empty($value) && empty($elements)) {
1532 | return ['True', true];
1533 | }
1534 | }
1535 |
1536 | $currentValues = [];
1537 | if (is_bool($value)) {
1538 | $currentValues = [false];
1539 | }
1540 |
1541 | foreach ($elements as $el) {
1542 | switch ($el->getTagName()) {
1543 | case 'input':
1544 | if ($el->getAttribute('type') === 'radio' || $el->getAttribute('type') === 'checkbox') {
1545 | if ($el->getAttribute('checked')) {
1546 | if (is_bool($value)) {
1547 | $currentValues = [true];
1548 | break;
1549 | } else {
1550 | $currentValues[] = $el->getAttribute('value');
1551 | }
1552 | }
1553 | } else {
1554 | $currentValues[] = $el->getAttribute('value');
1555 | }
1556 |
1557 | break;
1558 | case 'option':
1559 | if (!$el->isSelected()) {
1560 | break;
1561 | }
1562 |
1563 | $currentValues[] = $el->getText();
1564 | // no break we need the trim text and the value also
1565 | case 'textarea':
1566 | $currentValues[] = trim($el->getText());
1567 | // we include trimmed and real value of textarea for check
1568 | default:
1569 | $currentValues[] = $el->getAttribute('value'); // raw value
1570 | break;
1571 | }
1572 | }
1573 |
1574 | return [
1575 | 'Contains',
1576 | $value,
1577 | $currentValues,
1578 | "Failed testing for '{$value}' in {$strField}'s value: '" . implode("', '", $currentValues) . "'"
1579 | ];
1580 | }
1581 |
1582 | public function selectOption($select, $option): void
1583 | {
1584 | $el = $this->findField($select);
1585 | if ($el->getTagName() != 'select') {
1586 | $els = $this->matchCheckables($select);
1587 | $radio = null;
1588 | foreach ($els as $el) {
1589 | $radio = $this->findCheckable($el, $option, true);
1590 | if ($radio) {
1591 | break;
1592 | }
1593 | }
1594 |
1595 | if (!$radio) {
1596 | throw new ElementNotFound($select, "Radiobutton with value or name '{$option} in");
1597 | }
1598 |
1599 | $radio->click();
1600 | return;
1601 | }
1602 |
1603 | $wdSelect = new WebDriverSelect($el);
1604 | if ($wdSelect->isMultiple()) {
1605 | $wdSelect->deselectAll();
1606 | }
1607 |
1608 | if (!is_array($option)) {
1609 | $option = [$option];
1610 | }
1611 |
1612 | $matched = false;
1613 |
1614 | if (key($option) !== 'value') {
1615 | foreach ($option as $opt) {
1616 | try {
1617 | $wdSelect->selectByVisibleText($opt);
1618 | $matched = true;
1619 | } catch (NoSuchElementException $exception) {
1620 | }
1621 | }
1622 | }
1623 |
1624 | if ($matched) {
1625 | return;
1626 | }
1627 |
1628 | if (key($option) !== 'text') {
1629 | foreach ($option as $opt) {
1630 | try {
1631 | $wdSelect->selectByValue($opt);
1632 | $matched = true;
1633 | } catch (NoSuchElementException $exception) {
1634 | }
1635 | }
1636 | }
1637 |
1638 | if ($matched) {
1639 | return;
1640 | }
1641 |
1642 | // partially matching
1643 | foreach ($option as $opt) {
1644 | try {
1645 | $optElement = $el->findElement(WebDriverBy::xpath('.//option [contains (., "' . $opt . '")]'));
1646 | $matched = true;
1647 | if (!$optElement->isSelected()) {
1648 | $optElement->click();
1649 | }
1650 | } catch (NoSuchElementException $exception) {
1651 | // exception treated at the end
1652 | }
1653 | }
1654 |
1655 | if ($matched) {
1656 | return;
1657 | }
1658 |
1659 | throw new ElementNotFound(
1660 | json_encode($option, JSON_THROW_ON_ERROR),
1661 | "Option inside {$select} matched by name or value"
1662 | );
1663 | }
1664 |
1665 | /**
1666 | * Manually starts a new browser session.
1667 | *
1668 | * ```php
1669 | * getModule('WebDriver')->_initializeSession();
1671 | * ```
1672 | *
1673 | * @api
1674 | */
1675 | public function _initializeSession(): void
1676 | {
1677 | try {
1678 | $this->sessions[] = $this->webDriver;
1679 | $this->webDriver = RemoteWebDriver::create(
1680 | $this->wdHost,
1681 | $this->capabilities,
1682 | $this->connectionTimeoutInMs,
1683 | $this->requestTimeoutInMs,
1684 | $this->webdriverProxy,
1685 | $this->webdriverProxyPort
1686 | );
1687 | if (!is_null($this->config['pageload_timeout'])) {
1688 | $this->webDriver->manage()->timeouts()->pageLoadTimeout($this->config['pageload_timeout']);
1689 | }
1690 |
1691 | $this->setBaseElement();
1692 | $this->initialWindowSize();
1693 | } catch (UnexpectedResponseException $exception) {
1694 | codecept_debug('Curl error: ' . $exception->getMessage());
1695 | throw new ConnectionException(
1696 | "Can't connect to WebDriver at {$this->wdHost}."
1697 | . ' Make sure that ChromeDriver, GeckoDriver or Selenium Server is running.'
1698 | );
1699 | }
1700 | }
1701 |
1702 | /**
1703 | * Loads current RemoteWebDriver instance as a session
1704 | *
1705 | * @param RemoteWebDriver $session
1706 | * @api
1707 | */
1708 | public function _loadSession($session): void
1709 | {
1710 | $this->webDriver = $session;
1711 | $this->setBaseElement();
1712 | }
1713 |
1714 | /**
1715 | * Returns current WebDriver session for saving
1716 | *
1717 | * @api
1718 | */
1719 | public function _backupSession(): WebDriverInterface
1720 | {
1721 | return $this->webDriver;
1722 | }
1723 |
1724 | /**
1725 | * Manually closes current WebDriver session.
1726 | *
1727 | * ```php
1728 | * getModule('WebDriver')->_closeSession();
1730 | *
1731 | * // close a specific session
1732 | * $webDriver = $this->getModule('WebDriver')->webDriver;
1733 | * $this->getModule('WebDriver')->_closeSession($webDriver);
1734 | * ```
1735 | *
1736 | * @api
1737 | * @param RemoteWebDriver|null $webDriver a specific webdriver session instance
1738 | */
1739 | public function _closeSession($webDriver = null): void
1740 | {
1741 | if (!$webDriver && $this->webDriver) {
1742 | $webDriver = $this->webDriver;
1743 | }
1744 |
1745 | if (!$webDriver) {
1746 | return;
1747 | }
1748 |
1749 | try {
1750 | $webDriver->quit();
1751 | unset($webDriver);
1752 | } catch (PhpWebDriverExceptionInterface $exception) {
1753 | // Session already closed so nothing to do
1754 | }
1755 | }
1756 |
1757 | /**
1758 | * Unselect an option in the given select box.
1759 | *
1760 | * @param string|array|WebDriverBy $select
1761 | * @param string|array|WebDriverBy $option
1762 | */
1763 | public function unselectOption($select, $option): void
1764 | {
1765 | $el = $this->findField($select);
1766 |
1767 | $wdSelect = new WebDriverSelect($el);
1768 |
1769 | if (!is_array($option)) {
1770 | $option = [$option];
1771 | }
1772 |
1773 | $matched = false;
1774 |
1775 | foreach ($option as $opt) {
1776 | try {
1777 | $wdSelect->deselectByVisibleText($opt);
1778 | $matched = true;
1779 | } catch (NoSuchElementException $e) {
1780 | // exception treated at the end
1781 | }
1782 |
1783 | try {
1784 | $wdSelect->deselectByValue($opt);
1785 | $matched = true;
1786 | } catch (NoSuchElementException $e) {
1787 | // exception treated at the end
1788 | }
1789 | }
1790 |
1791 | if ($matched) {
1792 | return;
1793 | }
1794 |
1795 | throw new ElementNotFound(json_encode($option), "Option inside {$select} matched by name or value");
1796 | }
1797 |
1798 | /**
1799 | * @param string|array|WebDriverBy|WebDriverElement $radioOrCheckbox
1800 | */
1801 | protected function findCheckable(
1802 | WebDriverSearchContext $context,
1803 | $radioOrCheckbox,
1804 | bool $byValue = false
1805 | ): ?WebDriverElement {
1806 | if ($radioOrCheckbox instanceof WebDriverElement) {
1807 | return $radioOrCheckbox;
1808 | }
1809 |
1810 | if (is_array($radioOrCheckbox) || $radioOrCheckbox instanceof WebDriverBy) {
1811 | return $this->matchFirstOrFail($this->getBaseElement(), $radioOrCheckbox);
1812 | }
1813 |
1814 | $locator = self::xPathLiteral($radioOrCheckbox);
1815 | if ($context instanceof WebDriverElement && $context->getTagName() === 'input') {
1816 | $contextType = $context->getAttribute('type');
1817 | if (!in_array($contextType, ['checkbox', 'radio'], true)) {
1818 | return null;
1819 | }
1820 |
1821 | $nameLiteral = self::xPathLiteral($context->getAttribute('name'));
1822 | $typeLiteral = self::xPathLiteral($contextType);
1823 | $inputLocatorFragment = "input[@type = {$typeLiteral}][@name = {$nameLiteral}]";
1824 | $xpath = Locator::combine(
1825 | "ancestor::form//{$inputLocatorFragment}[(@id = ancestor::form//label[contains(normalize-space(string(.)), {$locator})]/@for) or @placeholder = {$locator}]",
1826 | "ancestor::form//label[contains(normalize-space(string(.)), {$locator})]//{$inputLocatorFragment}"
1827 | );
1828 | if ($byValue) {
1829 | $xpath = Locator::combine($xpath, "ancestor::form//{$inputLocatorFragment}[@value = {$locator}]");
1830 | }
1831 | } else {
1832 | $xpath = Locator::combine(
1833 | "//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[contains(normalize-space(string(.)), {$locator})]/@for) or @placeholder = {$locator} or @name = {$locator}]",
1834 | "//label[contains(normalize-space(string(.)), {$locator})]//input[@type = 'radio' or @type = 'checkbox']"
1835 | );
1836 | if ($byValue) {
1837 | $xpath = Locator::combine(
1838 | $xpath,
1839 | sprintf("//input[@type = 'checkbox' or @type = 'radio'][@value = %s]", $locator)
1840 | );
1841 | }
1842 | }
1843 |
1844 | $els = $context->findElements(WebDriverBy::xpath($xpath));
1845 | if (count($els) > 0) {
1846 | return reset($els);
1847 | }
1848 |
1849 | $els = $context->findElements(WebDriverBy::xpath(str_replace('ancestor::form', '', $xpath)));
1850 | if (count($els) > 0) {
1851 | return reset($els);
1852 | }
1853 |
1854 | $els = $this->match($context, $radioOrCheckbox);
1855 | if (count($els) > 0) {
1856 | return reset($els);
1857 | }
1858 |
1859 | return null;
1860 | }
1861 |
1862 | /**
1863 | * @param string|array|WebDriverBy $selector
1864 | * @return WebDriverElement[]
1865 | */
1866 | protected function matchCheckables($selector): array
1867 | {
1868 | $els = $this->match($this->webDriver, $selector);
1869 | if ($els === []) {
1870 | throw new ElementNotFound($selector, "Element containing radio by CSS or XPath");
1871 | }
1872 |
1873 | return $els;
1874 | }
1875 |
1876 | public function checkOption($option): void
1877 | {
1878 | $field = $this->findCheckable($this->webDriver, $option);
1879 | if (!$field) {
1880 | throw new ElementNotFound($option, "Checkbox or Radio by Label or CSS or XPath");
1881 | }
1882 |
1883 | if ($field->isSelected()) {
1884 | return;
1885 | }
1886 |
1887 | $field->click();
1888 | }
1889 |
1890 | public function uncheckOption($option): void
1891 | {
1892 | $field = $this->findCheckable($this->getBaseElement(), $option);
1893 | if (!$field) {
1894 | throw new ElementNotFound($option, "Checkbox by Label or CSS or XPath");
1895 | }
1896 |
1897 | if (!$field->isSelected()) {
1898 | return;
1899 | }
1900 |
1901 | $field->click();
1902 | }
1903 |
1904 | public function fillField($field, $value): void
1905 | {
1906 | $el = $this->findField($field);
1907 | $el->clear();
1908 | $el->sendKeys((string)$value);
1909 | }
1910 |
1911 | /**
1912 | * Clears given field which isn't empty.
1913 | *
1914 | * ``` php
1915 | * clearField('#username');
1917 | * ```
1918 | *
1919 | * @param string|array|WebDriverBy $field
1920 | */
1921 | public function clearField($field): void
1922 | {
1923 | $el = $this->findField($field);
1924 | $el->clear();
1925 | }
1926 |
1927 | /**
1928 | * Type in characters on active element.
1929 | * With a second parameter you can specify delay between key presses.
1930 | *
1931 | * ```php
1932 | * click('#input');
1935 | *
1936 | * // type text in active element
1937 | * $I->type('Hello world');
1938 | *
1939 | * // type text with a 1sec delay between chars
1940 | * $I->type('Hello World', 1);
1941 | * ```
1942 | *
1943 | * This might be useful when you an input reacts to typing and you need to slow it down to emulate human behavior.
1944 | * For instance, this is how Credit Card fields can be filled in.
1945 | *
1946 | * @param int $delay [sec]
1947 | */
1948 | public function type(string $text, int $delay = 0): void
1949 | {
1950 | $keys = str_split($text);
1951 | foreach ($keys as $key) {
1952 | sleep($delay);
1953 | $this->webDriver->getKeyboard()->pressKey($key);
1954 | }
1955 |
1956 | sleep($delay);
1957 | }
1958 |
1959 | public function attachFile($field, string $filename): void
1960 | {
1961 | $el = $this->findField($field);
1962 | // in order to be compatible on different OS
1963 | $filePath = codecept_data_dir() . $filename;
1964 | if (!file_exists($filePath)) {
1965 | throw new InvalidArgumentException("File does not exist: {$filePath}");
1966 | }
1967 |
1968 | if (!is_readable($filePath)) {
1969 | throw new InvalidArgumentException("File is not readable: {$filePath}");
1970 | }
1971 |
1972 | // in order for remote upload to be enabled
1973 | $el->setFileDetector(new LocalFileDetector());
1974 |
1975 | // skip file detector for phantomjs
1976 | if ($this->isPhantom()) {
1977 | $el->setFileDetector(new UselessFileDetector());
1978 | }
1979 |
1980 | $el->sendKeys(realpath($filePath));
1981 | }
1982 |
1983 | /**
1984 | * Grabs all visible text from the current page.
1985 | */
1986 | protected function getVisibleText(): ?string
1987 | {
1988 | if ($this->getBaseElement() instanceof RemoteWebElement) {
1989 | return $this->getBaseElement()->getText();
1990 | }
1991 |
1992 | $els = $this->getBaseElement()->findElements(WebDriverBy::cssSelector('body'));
1993 | if (isset($els[0])) {
1994 | return $els[0]->getText();
1995 | }
1996 |
1997 | return '';
1998 | }
1999 |
2000 | public function grabTextFrom($cssOrXPathOrRegex): mixed
2001 | {
2002 | $els = $this->match($this->getBaseElement(), $cssOrXPathOrRegex, false);
2003 | if ($els !== []) {
2004 | return $els[0]->getText();
2005 | }
2006 |
2007 | if (
2008 | is_string($cssOrXPathOrRegex)
2009 | && @preg_match($cssOrXPathOrRegex, $this->webDriver->getPageSource(), $matches)
2010 | ) {
2011 | return $matches[1];
2012 | }
2013 |
2014 | throw new ElementNotFound($cssOrXPathOrRegex, 'CSS or XPath or Regex');
2015 | }
2016 |
2017 | public function grabAttributeFrom($cssOrXpath, $attribute): ?string
2018 | {
2019 | $el = $this->matchFirstOrFail($this->getBaseElement(), $cssOrXpath);
2020 | return $el->getAttribute($attribute);
2021 | }
2022 |
2023 | public function grabValueFrom($field): ?string
2024 | {
2025 | $el = $this->findField($field);
2026 | // value of multiple select is the value of the first selected option
2027 | if ($el->getTagName() == 'select') {
2028 | $select = new WebDriverSelect($el);
2029 | return $select->getFirstSelectedOption()->getAttribute('value');
2030 | }
2031 |
2032 | return $el->getAttribute('value');
2033 | }
2034 |
2035 | public function grabMultiple($cssOrXpath, $attribute = null): array
2036 | {
2037 | $els = $this->match($this->getBaseElement(), $cssOrXpath);
2038 | return array_map(
2039 | function (WebDriverElement $e) use ($attribute): ?string {
2040 | if ($attribute) {
2041 | return $e->getAttribute($attribute);
2042 | }
2043 |
2044 | return $e->getText();
2045 | },
2046 | $els
2047 | );
2048 | }
2049 |
2050 | protected function filterByAttributes($els, array $attributes)
2051 | {
2052 | foreach ($attributes as $attr => $value) {
2053 | $els = array_filter(
2054 | $els,
2055 | fn(WebDriverElement $el): bool => $el->getAttribute($attr) == $value
2056 | );
2057 | }
2058 |
2059 | return $els;
2060 | }
2061 |
2062 | public function seeElement($selector, array $attributes = []): void
2063 | {
2064 | $this->enableImplicitWait();
2065 | $els = $this->matchVisible($selector);
2066 | $this->disableImplicitWait();
2067 | $els = $this->filterByAttributes($els, $attributes);
2068 | $this->assertNotEmpty($els);
2069 | }
2070 |
2071 | public function dontSeeElement($selector, array $attributes = []): void
2072 | {
2073 | $els = $this->matchVisible($selector);
2074 | $els = $this->filterByAttributes($els, $attributes);
2075 | $this->assertEmpty($els);
2076 | }
2077 |
2078 | /**
2079 | * Checks that the given element exists on the page, even it is invisible.
2080 | *
2081 | * ``` php
2082 | * seeElementInDOM('//form/input[type=hidden]');
2084 | * ```
2085 | *
2086 | * @param string|array|WebDriverBy $selector
2087 | */
2088 | public function seeElementInDOM($selector, array $attributes = []): void
2089 | {
2090 | $this->enableImplicitWait();
2091 | $els = $this->match($this->getBaseElement(), $selector);
2092 | $els = $this->filterByAttributes($els, $attributes);
2093 | $this->disableImplicitWait();
2094 | $this->assertNotEmpty($els);
2095 | }
2096 |
2097 |
2098 | /**
2099 | * Opposite of `seeElementInDOM`.
2100 | *
2101 | * @param string|array|WebDriverBy $selector
2102 | */
2103 | public function dontSeeElementInDOM($selector, array $attributes = []): void
2104 | {
2105 | $els = $this->match($this->getBaseElement(), $selector);
2106 | $els = $this->filterByAttributes($els, $attributes);
2107 | $this->assertEmpty($els);
2108 | }
2109 |
2110 | public function seeNumberOfElements($selector, $expected): void
2111 | {
2112 | $counted = count($this->matchVisible($selector));
2113 | if (is_array($expected)) {
2114 | [$floor, $ceil] = $expected;
2115 | $this->assertTrue(
2116 | $floor <= $counted && $ceil >= $counted,
2117 | 'Number of elements counted differs from expected range'
2118 | );
2119 | } else {
2120 | $this->assertSame(
2121 | $expected,
2122 | $counted,
2123 | 'Number of elements counted differs from expected number'
2124 | );
2125 | }
2126 | }
2127 |
2128 | /**
2129 | * @param string|array|WebDriverBy $selector
2130 | * @param int|array $expected
2131 | * @throws ModuleException
2132 | */
2133 | public function seeNumberOfElementsInDOM($selector, $expected)
2134 | {
2135 | $counted = count($this->match($this->getBaseElement(), $selector));
2136 | if (is_array($expected)) {
2137 | [$floor, $ceil] = $expected;
2138 | $this->assertTrue(
2139 | $floor <= $counted && $ceil >= $counted,
2140 | 'Number of elements counted differs from expected range'
2141 | );
2142 | } else {
2143 | $this->assertSame(
2144 | $expected,
2145 | $counted,
2146 | 'Number of elements counted differs from expected number'
2147 | );
2148 | }
2149 | }
2150 |
2151 | public function seeOptionIsSelected($selector, $optionText): void
2152 | {
2153 | $el = $this->findField($selector);
2154 | if ($el->getTagName() !== 'select') {
2155 | $els = $this->matchCheckables($selector);
2156 | foreach ($els as $k => $el) {
2157 | $els[$k] = $this->findCheckable($el, $optionText, true);
2158 | }
2159 |
2160 | $this->assertNotEmpty(
2161 | array_filter(
2162 | $els,
2163 | fn($e): bool => $e && $e->isSelected()
2164 | )
2165 | );
2166 | } else {
2167 | $select = new WebDriverSelect($el);
2168 | $this->assertNodesContain($optionText, $select->getAllSelectedOptions(), 'option');
2169 | }
2170 | }
2171 |
2172 | public function dontSeeOptionIsSelected($selector, $optionText): void
2173 | {
2174 | $el = $this->findField($selector);
2175 | if ($el->getTagName() !== 'select') {
2176 | $els = $this->matchCheckables($selector);
2177 | foreach ($els as $k => $el) {
2178 | $els[$k] = $this->findCheckable($el, $optionText, true);
2179 | }
2180 |
2181 | $this->assertEmpty(
2182 | array_filter(
2183 | $els,
2184 | fn($e): bool => $e && $e->isSelected()
2185 | )
2186 | );
2187 | } else {
2188 | $select = new WebDriverSelect($el);
2189 | $this->assertNodesNotContain($optionText, $select->getAllSelectedOptions(), 'option');
2190 | }
2191 | }
2192 |
2193 | public function seeInTitle($title)
2194 | {
2195 | $this->assertStringContainsString($title, $this->webDriver->getTitle());
2196 | }
2197 |
2198 | public function dontSeeInTitle($title)
2199 | {
2200 | $this->assertStringNotContainsString($title, $this->webDriver->getTitle());
2201 | }
2202 |
2203 | /**
2204 | * Accepts the active JavaScript native popup window, as created by `window.alert`|`window.confirm`|`window.prompt`.
2205 | * Don't confuse popups with modal windows,
2206 | * as created by [various libraries](https://jster.net/category/windows-modals-popups).
2207 | */
2208 | public function acceptPopup(): void
2209 | {
2210 | if ($this->isPhantom()) {
2211 | throw new ModuleException($this, 'PhantomJS does not support working with popups');
2212 | }
2213 |
2214 | $this->webDriver->switchTo()->alert()->accept();
2215 | }
2216 |
2217 | /**
2218 | * Dismisses the active JavaScript popup, as created by `window.alert`, `window.confirm`, or `window.prompt`.
2219 | */
2220 | public function cancelPopup(): void
2221 | {
2222 | if ($this->isPhantom()) {
2223 | throw new ModuleException($this, 'PhantomJS does not support working with popups');
2224 | }
2225 |
2226 | $this->webDriver->switchTo()->alert()->dismiss();
2227 | }
2228 |
2229 | /**
2230 | * Checks that the active JavaScript popup,
2231 | * as created by `window.alert`|`window.confirm`|`window.prompt`, contains the given string.
2232 | *
2233 | * @throws ModuleException
2234 | */
2235 | public function seeInPopup(string $text): void
2236 | {
2237 | if ($this->isPhantom()) {
2238 | throw new ModuleException($this, 'PhantomJS does not support working with popups');
2239 | }
2240 |
2241 | $alert = $this->webDriver->switchTo()->alert();
2242 | try {
2243 | $this->assertStringContainsString($text, $alert->getText());
2244 | } catch (PHPUnitAssertionFailedError $failedError) {
2245 | $alert->dismiss();
2246 | throw $failedError;
2247 | }
2248 | }
2249 |
2250 | /**
2251 | * Checks that the active JavaScript popup,
2252 | * as created by `window.alert`|`window.confirm`|`window.prompt`, does NOT contain the given string.
2253 | *
2254 | * @throws ModuleException
2255 | */
2256 | public function dontSeeInPopup(string $text): void
2257 | {
2258 | if ($this->isPhantom()) {
2259 | throw new ModuleException($this, 'PhantomJS does not support working with popups');
2260 | }
2261 |
2262 | $alert = $this->webDriver->switchTo()->alert();
2263 | try {
2264 | $this->assertStringNotContainsString($text, $alert->getText());
2265 | } catch (PHPUnitAssertionFailedError $e) {
2266 | $alert->dismiss();
2267 | throw $e;
2268 | }
2269 | }
2270 |
2271 | /**
2272 | * Enters text into a native JavaScript prompt popup, as created by `window.prompt`.
2273 | *
2274 | * @throws ModuleException
2275 | */
2276 | public function typeInPopup(string $keys): void
2277 | {
2278 | if ($this->isPhantom()) {
2279 | throw new ModuleException($this, 'PhantomJS does not support working with popups');
2280 | }
2281 |
2282 | $this->webDriver->switchTo()->alert()->sendKeys($keys);
2283 | }
2284 |
2285 | /**
2286 | * Reloads the current page. All forms will be reset, so the outcome is as if the user would press Ctrl+F5.
2287 | */
2288 | public function reloadPage(): void
2289 | {
2290 | $this->webDriver->navigate()->refresh();
2291 | }
2292 |
2293 | /**
2294 | * Moves back in history.
2295 | */
2296 | public function moveBack(): void
2297 | {
2298 | $this->webDriver->navigate()->back();
2299 | $this->debug($this->_getCurrentUri());
2300 | }
2301 |
2302 | /**
2303 | * Moves forward in history.
2304 | */
2305 | public function moveForward(): void
2306 | {
2307 | $this->webDriver->navigate()->forward();
2308 | $this->debug($this->_getCurrentUri());
2309 | }
2310 |
2311 | protected function getSubmissionFormFieldName(string $name): string
2312 | {
2313 | if (substr($name, -2) === '[]') {
2314 | return substr($name, 0, -2);
2315 | }
2316 |
2317 | return $name;
2318 | }
2319 |
2320 | /**
2321 | * Submits the given form on the page, optionally with the given form
2322 | * values. Give the form fields values as an array. Note that hidden fields
2323 | * can't be accessed.
2324 | *
2325 | * Skipped fields will be filled by their values from the page.
2326 | * You don't need to click the 'Submit' button afterwards.
2327 | * This command itself triggers the request to form's action.
2328 | *
2329 | * You can optionally specify what button's value to include
2330 | * in the request with the last parameter as an alternative to
2331 | * explicitly setting its value in the second parameter, as
2332 | * button values are not otherwise included in the request.
2333 | *
2334 | * Examples:
2335 | *
2336 | * ``` php
2337 | * submitForm('#login', [
2339 | * 'login' => 'davert',
2340 | * 'password' => '123456'
2341 | * ]);
2342 | * // or
2343 | * $I->submitForm('#login', [
2344 | * 'login' => 'davert',
2345 | * 'password' => '123456'
2346 | * ], 'submitButtonName');
2347 | *
2348 | * ```
2349 | *
2350 | * For example, given this sample "Sign Up" form:
2351 | *
2352 | * ``` html
2353 | *
2367 | * ```
2368 | *
2369 | * You could write the following to submit it:
2370 | *
2371 | * ``` php
2372 | * submitForm(
2374 | * '#userForm',
2375 | * [
2376 | * 'user[login]' => 'Davert',
2377 | * 'user[password]' => '123456',
2378 | * 'user[agree]' => true
2379 | * ],
2380 | * 'submitButton'
2381 | * );
2382 | * ```
2383 | * Note that "2" will be the submitted value for the "plan" field, as it is
2384 | * the selected option.
2385 | *
2386 | * Also note that this differs from PhpBrowser, in that
2387 | * ```'user' => [ 'login' => 'Davert' ]``` is not supported at the moment.
2388 | * Named array keys *must* be included in the name as above.
2389 | *
2390 | * Pair this with seeInFormFields for quick testing magic.
2391 | *
2392 | * ``` php
2393 | * 'value',
2396 | * 'field2' => 'another value',
2397 | * 'checkbox1' => true,
2398 | * // ...
2399 | * ];
2400 | * $I->submitForm('//form[@id=my-form]', $form, 'submitButton');
2401 | * // $I->amOnPage('/path/to/form-page') may be needed
2402 | * $I->seeInFormFields('//form[@id=my-form]', $form);
2403 | * ```
2404 | *
2405 | * Parameter values must be set to arrays for multiple input fields
2406 | * of the same name, or multi-select combo boxes. For checkboxes,
2407 | * either the string value can be used, or boolean values which will
2408 | * be replaced by the checkbox's value in the DOM.
2409 | *
2410 | * ``` php
2411 | * submitForm('#my-form', [
2413 | * 'field1' => 'value',
2414 | * 'checkbox' => [
2415 | * 'value of first checkbox',
2416 | * 'value of second checkbox',
2417 | * ],
2418 | * 'otherCheckboxes' => [
2419 | * true,
2420 | * false,
2421 | * false,
2422 | * ],
2423 | * 'multiselect' => [
2424 | * 'first option value',
2425 | * 'second option value',
2426 | * ]
2427 | * ]);
2428 | * ```
2429 | *
2430 | * Mixing string and boolean values for a checkbox's value is not supported
2431 | * and may produce unexpected results.
2432 | *
2433 | * Field names ending in "[]" must be passed without the trailing square
2434 | * bracket characters, and must contain an array for its value. This allows
2435 | * submitting multiple values with the same name, consider:
2436 | *
2437 | * ```php
2438 | * $I->submitForm('#my-form', [
2439 | * 'field[]' => 'value',
2440 | * 'field[]' => 'another value', // 'field[]' is already a defined key
2441 | * ]);
2442 | * ```
2443 | *
2444 | * The solution is to pass an array value:
2445 | *
2446 | * ```php
2447 | * // this way both values are submitted
2448 | * $I->submitForm('#my-form', [
2449 | * 'field' => [
2450 | * 'value',
2451 | * 'another value',
2452 | * ]
2453 | * ]);
2454 | * ```
2455 | *
2456 | * The `$button` parameter can be either a string, an array or an instance
2457 | * of Facebook\WebDriver\WebDriverBy. When it is a string, the
2458 | * button will be found by its "name" attribute. If $button is an
2459 | * array then it will be treated as a strict selector and a WebDriverBy
2460 | * will be used verbatim.
2461 | *
2462 | * For example, given the following HTML:
2463 | *
2464 | * ``` html
2465 | *
2466 | * ```
2467 | *
2468 | * `$button` could be any one of the following:
2469 | * - 'submitButton'
2470 | * - ['name' => 'submitButton']
2471 | * - WebDriverBy::name('submitButton')
2472 | *
2473 | * @param string|array|WebDriverBy $selector
2474 | * @param string|array|WebDriverBy|null $button
2475 | */
2476 | public function submitForm($selector, array $params, $button = null): void
2477 | {
2478 | $form = $this->matchFirstOrFail($this->getBaseElement(), $selector);
2479 |
2480 | $fields = $form->findElements(
2481 | WebDriverBy::cssSelector(
2482 | 'input:enabled[name],textarea:enabled[name],select:enabled[name],input[type=hidden][name]'
2483 | )
2484 | );
2485 | foreach ($fields as $field) {
2486 | $fieldName = $this->getSubmissionFormFieldName($field->getAttribute('name') ?? '');
2487 | if (!isset($params[$fieldName])) {
2488 | continue;
2489 | }
2490 |
2491 | $value = $params[$fieldName];
2492 | if (is_array($value) && $field->getTagName() !== 'select') {
2493 | if ($field->getAttribute('type') === 'checkbox' || $field->getAttribute('type') === 'radio') {
2494 | $found = false;
2495 | foreach ($value as $index => $val) {
2496 | if (!is_bool($val) && $val === $field->getAttribute('value')) {
2497 | array_splice($params[$fieldName], $index, 1);
2498 | $value = $val;
2499 | $found = true;
2500 | break;
2501 | }
2502 | }
2503 |
2504 | if (!$found && !empty($value) && is_bool(reset($value))) {
2505 | $value = array_pop($params[$fieldName]);
2506 | }
2507 | } else {
2508 | $value = array_pop($params[$fieldName]);
2509 | }
2510 | }
2511 |
2512 | if ($field->getAttribute('type') === 'checkbox' || $field->getAttribute('type') === 'radio') {
2513 | if ($value === true || $value === $field->getAttribute('value')) {
2514 | $this->checkOption($field);
2515 | } else {
2516 | $this->uncheckOption($field);
2517 | }
2518 | } elseif ($field->getAttribute('type') === 'button' || $field->getAttribute('type') === 'submit') {
2519 | continue;
2520 | } elseif ($field->getTagName() === 'select') {
2521 | $this->selectOption($field, $value);
2522 | } else {
2523 | $this->fillField($field, $value);
2524 | }
2525 | }
2526 |
2527 | $this->debugSection(
2528 | 'Uri',
2529 | $form->getAttribute('action') ? $form->getAttribute('action') : $this->_getCurrentUri()
2530 | );
2531 | $this->debugSection('Method', $form->getAttribute('method') ? $form->getAttribute('method') : 'GET');
2532 | $this->debugSection('Parameters', json_encode($params, JSON_THROW_ON_ERROR));
2533 |
2534 | $submitted = false;
2535 | if (!empty($button)) {
2536 | if (is_array($button)) {
2537 | $buttonSelector = $this->getStrictLocator($button);
2538 | } elseif ($button instanceof WebDriverBy) {
2539 | $buttonSelector = $button;
2540 | } else {
2541 | $buttonSelector = WebDriverBy::name($button);
2542 | }
2543 |
2544 | $els = $form->findElements($buttonSelector);
2545 |
2546 | if (!empty($els)) {
2547 | $el = reset($els);
2548 | $el->click();
2549 | $submitted = true;
2550 | }
2551 | }
2552 |
2553 | if (!$submitted) {
2554 | $form->submit();
2555 | }
2556 |
2557 | $this->debugSection('Page', $this->_getCurrentUri());
2558 | }
2559 |
2560 | /**
2561 | * Waits up to `$timeout` seconds for the given element to change.
2562 | * Element "change" is determined by a callback function which is called repeatedly
2563 | * until the return value evaluates to true.
2564 | *
2565 | * ``` php
2566 | * waitForElementChange('#menu', function(WebDriverElement $element) {
2570 | * return $element->isDisplayed();
2571 | * }, 5);
2572 | * ```
2573 | *
2574 | * @param string|array|WebDriverBy $element
2575 | * @throws ElementNotFound
2576 | */
2577 | public function waitForElementChange($element, Closure $callback, int $timeout = 30): void
2578 | {
2579 | $el = $this->matchFirstOrFail($this->getBaseElement(), $element);
2580 | $checker = fn() => $callback($el);
2581 | $this->webDriver->wait($timeout)->until($checker);
2582 | }
2583 |
2584 | /**
2585 | * Waits up to $timeout seconds for an element to appear on the page.
2586 | * If the element doesn't appear, a timeout exception is thrown.
2587 | *
2588 | * ``` php
2589 | * waitForElement('#agree_button', 30); // secs
2591 | * $I->click('#agree_button');
2592 | * ```
2593 | *
2594 | * @param string|array|WebDriverBy $element
2595 | * @param int $timeout seconds
2596 | * @throws Exception
2597 | */
2598 | public function waitForElement($element, int $timeout = 10): void
2599 | {
2600 | $condition = WebDriverExpectedCondition::presenceOfElementLocated($this->getLocator($element));
2601 | $this->webDriver->wait($timeout)->until($condition);
2602 | }
2603 |
2604 | /**
2605 | * Waits up to $timeout seconds for the given element to be visible on the page.
2606 | * If element doesn't appear, a timeout exception is thrown.
2607 | *
2608 | * ``` php
2609 | * waitForElementVisible('#agree_button', 30); // secs
2611 | * $I->click('#agree_button');
2612 | * ```
2613 | *
2614 | * @param string|array|WebDriverBy $element
2615 | * @param int $timeout seconds
2616 | * @throws Exception
2617 | */
2618 | public function waitForElementVisible($element, int $timeout = 10): void
2619 | {
2620 | $condition = WebDriverExpectedCondition::visibilityOfElementLocated($this->getLocator($element));
2621 | $this->webDriver->wait($timeout)->until($condition);
2622 | }
2623 |
2624 | /**
2625 | * Waits up to $timeout seconds for the given element to become invisible.
2626 | * If element stays visible, a timeout exception is thrown.
2627 | *
2628 | * ``` php
2629 | * waitForElementNotVisible('#agree_button', 30); // secs
2631 | * ```
2632 | *
2633 | * @param string|array|WebDriverBy $element
2634 | * @param int $timeout seconds
2635 | * @throws Exception
2636 | */
2637 | public function waitForElementNotVisible($element, int $timeout = 10): void
2638 | {
2639 | $condition = WebDriverExpectedCondition::invisibilityOfElementLocated($this->getLocator($element));
2640 | $this->webDriver->wait($timeout)->until($condition);
2641 | }
2642 |
2643 | /**
2644 | * Waits up to $timeout seconds for the given element to be clickable.
2645 | * If element doesn't become clickable, a timeout exception is thrown.
2646 | *
2647 | * ``` php
2648 | * waitForElementClickable('#agree_button', 30); // secs
2650 | * $I->click('#agree_button');
2651 | * ```
2652 | *
2653 | * @param string|array|WebDriverBy $element
2654 | * @param int $timeout seconds
2655 | * @throws Exception
2656 | */
2657 | public function waitForElementClickable($element, int $timeout = 10): void
2658 | {
2659 | $condition = WebDriverExpectedCondition::elementToBeClickable($this->getLocator($element));
2660 | $this->webDriver->wait($timeout)->until($condition);
2661 | }
2662 |
2663 | /**
2664 | * Waits up to $timeout seconds for the given string to appear on the page.
2665 | *
2666 | * Can also be passed a selector to search in, be as specific as possible when using selectors.
2667 | * waitForText() will only watch the first instance of the matching selector / text provided.
2668 | * If the given text doesn't appear, a timeout exception is thrown.
2669 | *
2670 | * ``` php
2671 | * waitForText('foo', 30); // secs
2673 | * $I->waitForText('foo', 30, '.title'); // secs
2674 | * ```
2675 | *
2676 | * @param int $timeout seconds
2677 | * @param null|string|array|WebDriverBy $selector
2678 | * @throws Exception
2679 | */
2680 | public function waitForText(string $text, int $timeout = 10, $selector = null): void
2681 | {
2682 | $message = sprintf(
2683 | 'Waited for %d secs but text %s still not found',
2684 | $timeout,
2685 | Locator::humanReadableString($text)
2686 | );
2687 | if (!$selector) {
2688 | $condition = WebDriverExpectedCondition::elementTextContains(WebDriverBy::xpath('//body'), $text);
2689 | $this->webDriver->wait($timeout)->until($condition, $message);
2690 | return;
2691 | }
2692 |
2693 | $condition = WebDriverExpectedCondition::elementTextContains($this->getLocator($selector), $text);
2694 | $this->webDriver->wait($timeout)->until($condition, $message);
2695 | }
2696 |
2697 | /**
2698 | * Wait for $timeout seconds.
2699 | *
2700 | * @param int|float $timeout secs
2701 | * @throws TestRuntimeException
2702 | */
2703 | public function wait($timeout): void
2704 | {
2705 | if ($timeout >= 1000) {
2706 | throw new TestRuntimeException(
2707 | "
2708 | Waiting for more then 1000 seconds: 16.6667 mins\n
2709 | Please note that wait method accepts number of seconds as parameter."
2710 | );
2711 | }
2712 |
2713 | usleep((int)($timeout * 1_000_000));
2714 | }
2715 |
2716 | /**
2717 | * Low-level API method.
2718 | * If Codeception commands are not enough, this allows you to use Selenium WebDriver methods directly:
2719 | *
2720 | * ``` php
2721 | * $I->executeInSelenium(function(\Facebook\WebDriver\Remote\RemoteWebDriver $webdriver) {
2722 | * $webdriver->get('https://google.com');
2723 | * });
2724 | * ```
2725 | *
2726 | * This runs in the context of the
2727 | * [RemoteWebDriver class](https://github.com/php-webdriver/php-webdriver/blob/master/lib/remote/RemoteWebDriver.php).
2728 | * Try not to use this command on a regular basis.
2729 | * If Codeception lacks a feature you need, please implement it and submit a patch.
2730 | *
2731 | * @param Closure $function
2732 | * @return mixed
2733 | */
2734 | public function executeInSelenium(Closure $function)
2735 | {
2736 | return $function($this->webDriver);
2737 | }
2738 |
2739 | /**
2740 | * Switch to another window identified by name.
2741 | *
2742 | * The window can only be identified by name. If the $name parameter is blank, the parent window will be used.
2743 | *
2744 | * Example:
2745 | * ``` html
2746 | *
2747 | * ```
2748 | *
2749 | * ``` php
2750 | * click("Open window");
2752 | * # switch to another window
2753 | * $I->switchToWindow("another_window");
2754 | * # switch to parent window
2755 | * $I->switchToWindow();
2756 | * ```
2757 | *
2758 | * If the window has no name, match it by switching to next active tab using `switchToNextTab` method.
2759 | *
2760 | * Or use native Selenium functions to get access to all opened windows:
2761 | *
2762 | * ``` php
2763 | * executeInSelenium(function (\Facebook\WebDriver\Remote\RemoteWebDriver $webdriver) {
2765 | * $handles=$webdriver->getWindowHandles();
2766 | * $last_window = end($handles);
2767 | * $webdriver->switchTo()->window($last_window);
2768 | * });
2769 | * ```
2770 | */
2771 | public function switchToWindow(?string $name = null): void
2772 | {
2773 | $this->webDriver->switchTo()->window($name);
2774 | }
2775 |
2776 | /**
2777 | * Switch to another iframe on the page.
2778 | *
2779 | * Example:
2780 | * ``` html
2781 | *