├── .github └── workflows │ ├── static-analysis.yml │ ├── webdriver-chrome-headless.yml │ ├── webdriver-chrome.yml │ └── webdriver-firefox.yml ├── LICENSE ├── composer.json ├── phpcs.xml └── src └── Codeception ├── Constraint ├── WebDriver.php └── WebDriverNot.php ├── Exception └── ConnectionException.php └── Module └── WebDriver.php /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static analysis 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | phpcs: 11 | name: Code style 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: '8.1' 21 | ini-values: memory_limit=-1, date.timezone='UTC' 22 | tools: phpcs, phpstan 23 | 24 | - name: Validate composer.json 25 | run: composer validate 26 | 27 | - name: Install dependencies 28 | run: composer update 29 | 30 | - name: Generate action files 31 | run: vendor/bin/codecept build 32 | 33 | - name: Check production code style 34 | run: phpcs src/ 35 | 36 | - name: Check test code style 37 | run: phpcs tests/ --standard=tests/phpcs.xml 38 | 39 | - name: Static analysis of production code 40 | run: phpstan analyze src/ --level=1 41 | 42 | - name: Static analysis of test code 43 | run: phpstan analyze tests/ 44 | -------------------------------------------------------------------------------- /.github/workflows/webdriver-chrome-headless.yml: -------------------------------------------------------------------------------- 1 | name: Chrome Headless Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - run: docker run -d --net=host --shm-size=2g selenium/standalone-chrome:3.141.59-oxygen 18 | 19 | - name: Validate composer.json and composer.lock 20 | run: composer validate --strict 21 | 22 | - name: Cache Composer packages 23 | id: composer-cache 24 | uses: actions/cache@v3 25 | with: 26 | path: vendor 27 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-php- 30 | 31 | - name: Install dependencies 32 | run: composer install --prefer-dist --no-progress 33 | 34 | - run: php ./vendor/bin/codecept build 35 | - name: Start dev server 36 | run: php -S 127.0.0.1:8000 -t tests/data/app >/dev/null 2>&1 & 37 | 38 | - name: Tests 39 | run: php ./vendor/bin/codecept run --env headless 40 | 41 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 42 | # Docs: https://getcomposer.org/doc/articles/scripts.md 43 | 44 | # - name: Run test suite 45 | # run: composer run-script test 46 | -------------------------------------------------------------------------------- /.github/workflows/webdriver-chrome.yml: -------------------------------------------------------------------------------- 1 | name: Chrome Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - run: docker run -d --net=host --shm-size=2g selenium/standalone-chrome:3.141.59-oxygen 18 | 19 | - name: Validate composer.json and composer.lock 20 | run: composer validate --strict 21 | 22 | - name: Cache Composer packages 23 | id: composer-cache 24 | uses: actions/cache@v3 25 | with: 26 | path: vendor 27 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-php- 30 | 31 | - name: Install dependencies 32 | run: composer install --prefer-dist --no-progress 33 | 34 | - run: php ./vendor/bin/codecept build 35 | - name: Start dev server 36 | run: php -S 127.0.0.1:8000 -t tests/data/app >/dev/null 2>&1 & 37 | 38 | - name: Tests 39 | run: php ./vendor/bin/codecept run --env chrome 40 | 41 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 42 | # Docs: https://getcomposer.org/doc/articles/scripts.md 43 | 44 | # - name: Run test suite 45 | # run: composer run-script test 46 | -------------------------------------------------------------------------------- /.github/workflows/webdriver-firefox.yml: -------------------------------------------------------------------------------- 1 | name: Firefox Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - run: docker run -d --net=host --shm-size=2g selenium/standalone-firefox:3.141.59-oxygen 18 | 19 | - name: Validate composer.json and composer.lock 20 | run: composer validate --strict 21 | 22 | - name: Cache Composer packages 23 | id: composer-cache 24 | uses: actions/cache@v2 25 | with: 26 | path: vendor 27 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-php- 30 | 31 | - name: Install dependencies 32 | run: composer install --prefer-dist --no-progress 33 | 34 | - run: php ./vendor/bin/codecept build 35 | - name: Start dev server 36 | run: php -S 127.0.0.1:8000 -t tests/data/app >/dev/null 2>&1 & 37 | 38 | - name: Tests 39 | run: php ./vendor/bin/codecept run --env firefox -vvv 40 | 41 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 42 | # Docs: https://getcomposer.org/doc/articles/scripts.md 43 | 44 | # - name: Run test suite 45 | # run: composer run-script test 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011 Michael Bodnarchuk and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"codeception/module-webdriver", 3 | "description":"WebDriver module for Codeception", 4 | "keywords":["codeception", "browser-testing", "acceptance-testing"], 5 | "homepage":"https://codeception.com/", 6 | "type":"library", 7 | "license":"MIT", 8 | "authors":[ 9 | { 10 | "name": "Michael Bodnarchuk" 11 | }, 12 | { 13 | "name": "Gintautas Miselis" 14 | }, 15 | { 16 | "name": "Zaahid Bateson" 17 | } 18 | ], 19 | "minimum-stability": "RC", 20 | 21 | "require": { 22 | "php": "^8.1", 23 | "ext-json": "*", 24 | "ext-mbstring": "*", 25 | "codeception/codeception": "^5.0.8", 26 | "codeception/lib-web": "^1.0.1", 27 | "codeception/stub": "^4.0", 28 | "php-webdriver/webdriver": "^1.14.0", 29 | "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" 30 | }, 31 | "suggest": { 32 | "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests" 33 | }, 34 | "autoload":{ 35 | "classmap": ["src/"] 36 | }, 37 | "autoload-dev": { 38 | "classmap": [ 39 | "tests/data/app/data.php", 40 | "tests/unit/Codeception/Constraints/TestedWebElement.php" 41 | ], 42 | "psr-4": { 43 | "Tests\\Web\\": "tests/web/" 44 | } 45 | }, 46 | "config": { 47 | "classmap-authoritative": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Codeception code standard 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/Codeception/Constraint/WebDriver.php: -------------------------------------------------------------------------------- 1 | string === '') { 31 | return true; 32 | } 33 | 34 | foreach ($nodes as $node) { 35 | if (!$node->isDisplayed()) { 36 | continue; 37 | } 38 | if (parent::matches(htmlspecialchars_decode($node->getText(), ENT_QUOTES | ENT_SUBSTITUTE))) { 39 | return true; 40 | } 41 | } 42 | return false; 43 | } 44 | 45 | /** 46 | * @param WebDriverElement[] $nodes 47 | * @param string|array|WebDriverBy $selector 48 | * @param ComparisonFailure|null $comparisonFailure 49 | */ 50 | protected function fail($nodes, $selector, ?ComparisonFailure $comparisonFailure = null): never 51 | { 52 | if (count($nodes) === 0) { 53 | throw new ElementNotFound($selector, 'Element located either by name, CSS or XPath'); 54 | } 55 | 56 | $output = "Failed asserting that any element by " . Locator::humanReadableString($selector); 57 | $output .= ' ' . $this->uriMessage('on page'); 58 | 59 | if (count($nodes) < 5) { 60 | $output .= "\nElements: "; 61 | $output .= $this->nodesList($nodes); 62 | } else { 63 | $message = new Message("[total %s elements]"); 64 | $output .= $message->with(count($nodes)); 65 | } 66 | $output .= "\ncontains text '" . $this->string . "'"; 67 | 68 | throw new ExpectationFailedException( 69 | $output, 70 | $comparisonFailure 71 | ); 72 | } 73 | 74 | /** 75 | * @param WebDriverElement[] $nodes 76 | * @return string 77 | */ 78 | protected function failureDescription($nodes): string 79 | { 80 | $desc = ''; 81 | foreach ($nodes as $node) { 82 | $desc .= parent::failureDescription($node->getText()); 83 | } 84 | return $desc; 85 | } 86 | 87 | /** 88 | * @param WebDriverElement[] $nodes 89 | * @param string|null $contains 90 | * @return string 91 | */ 92 | protected function nodesList(array $nodes, ?string $contains = null): string 93 | { 94 | $output = ""; 95 | foreach ($nodes as $node) { 96 | if ($contains && strpos($node->getText(), $contains) === false) { 97 | continue; 98 | } 99 | $message = new Message("\n+ <%s> %s"); 100 | $output .= $message->with($node->getTagName(), $node->getText()); 101 | } 102 | return $output; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Codeception/Constraint/WebDriverNot.php: -------------------------------------------------------------------------------- 1 | string) { 34 | throw new ExpectationFailedException( 35 | "Element {$selector} was found", 36 | $comparisonFailure 37 | ); 38 | } 39 | 40 | $output = "There was {$selector} element"; 41 | $output .= ' ' . $this->uriMessage('on page'); 42 | $output .= $this->nodesList($nodes, $this->string); 43 | $output .= "\ncontaining '{$this->string}'"; 44 | 45 | throw new ExpectationFailedException( 46 | $output, 47 | $comparisonFailure 48 | ); 49 | } 50 | 51 | public function toString(): string 52 | { 53 | if ($this->string) { 54 | return 'that contains text "' . $this->string . '"'; 55 | } 56 | return ''; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Codeception/Exception/ConnectionException.php: -------------------------------------------------------------------------------- 1 | :@ondemand.saucelabs.com' 185 | * port: 80 186 | * browser: chrome 187 | * capabilities: 188 | * platformName: 'Windows 10' 189 | * ``` 190 | * 191 | * ### BrowserStack 192 | * 193 | * 1. Create an account at [BrowserStack](https://www.browserstack.com/) to get your username and access key 194 | * 2. In the module configuration use the format `username`:`access_key`@hub.browserstack.com' for `host` 195 | * 3. Configure `os` and `os_version` under `capabilities` to define the operating System 196 | * 4. If your site is available only locally or via VPN you should use a tunnel app. In this case add `browserstack.local` capability and set it to true. 197 | * 198 | * ```yaml 199 | * modules: 200 | * enabled: 201 | * - WebDriver: 202 | * url: http://mysite.com 203 | * host: ':@hub.browserstack.com' 204 | * port: 80 205 | * browser: chrome 206 | * capabilities: 207 | * bstack:options: 208 | * os: Windows 209 | * osVersion: 10 210 | * local: true # for local testing 211 | * ``` 212 | * 213 | * ### LambdaTest 214 | * 215 | * 1. Create an account at [LambdaTest](https://www.lambdatest.com) to get your username and access key 216 | * 2. In the module configuration use the format `username`:`access key`@hub.lambdatest.com' for `host` 217 | * 3. Configure `platformName`, 'browserVersion', and 'browserName' under `LT:Options` to define test environments. 218 | * 4. If your website is available only locally or via VPN you should use LambdaTest tunnel. In this case, you can add capability "tunnel":true;. 219 | * 220 | * ```yaml 221 | * modules: 222 | * enabled: 223 | * - WebDriver: 224 | url: "https://openclassrooms.com" 225 | host: 'hub.lambdatest.com' 226 | port: 80 227 | browser: 'Chrome' 228 | capabilities: 229 | LT:Options: 230 | platformName: 'Windows 10' 231 | browserVersion: 'latest-5' 232 | browserName: 'Chrome' 233 | tunnel: true #for Local testing 234 | * ``` 235 | * 236 | * ### TestingBot 237 | * 238 | * 1. Create an account at [TestingBot](https://testingbot.com/) to get your key and secret 239 | * 2. In the module configuration use the format `key`:`secret`@hub.testingbot.com' for `host` 240 | * 3. Configure `platformName` under `capabilities` to define the [Operating System](https://testingbot.com/support/getting-started/browsers.html) 241 | * 4. Run [TestingBot Tunnel](https://testingbot.com/support/other/tunnel) if your site can't be accessed from Internet 242 | * 243 | * ```yaml 244 | * modules: 245 | * enabled: 246 | * - WebDriver: 247 | * url: http://mysite.com 248 | * host: ':@hub.testingbot.com' 249 | * port: 80 250 | * browser: chrome 251 | * capabilities: 252 | * platformName: Windows 10 253 | * ``` 254 | * 255 | * ## Configuration 256 | * 257 | * * `url` *required* - Base URL for your app (amOnPage opens URLs relative to this setting). 258 | * * `browser` *required* - Browser to launch. 259 | * * `host` - Selenium server host (127.0.0.1 by default). 260 | * * `port` - Selenium server port (4444 by default). 261 | * * `restart` - Set to `false` (default) to use the same browser window for all tests, or set to `true` to create a new window for each test. In any case, when all tests are finished the browser window is closed. 262 | * * `start` - Autostart a browser for tests. Can be disabled if browser session is started with `_initializeSession` inside a Helper. 263 | * * `window_size` - Initial window size. Set to `maximize` or a dimension in the format `640x480`. 264 | * * `clear_cookies` - Set to false to keep cookies, or set to true (default) to delete all cookies between tests. 265 | * * `wait` (default: 0 seconds) - Whenever element is required and is not on page, wait for n seconds to find it before fail. 266 | * * `capabilities` - Sets Selenium [desired capabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities). Should be a key-value array. 267 | * * `connection_timeout` - timeout for opening a connection to remote selenium server (30 seconds by default). 268 | * * `request_timeout` - timeout for a request to return something from remote selenium server (30 seconds by default). 269 | * * `pageload_timeout` - amount of time to wait for a page load to complete before throwing an error (default 0 seconds). 270 | * * `http_proxy` - sets http proxy server url for testing a remote server. 271 | * * `http_proxy_port` - sets http proxy server port 272 | * * `ssl_proxy` - sets ssl(https) proxy server url for testing a remote server. 273 | * * `ssl_proxy_port` - sets ssl(https) proxy server port 274 | * * `debug_log_entries` - how many selenium entries to print with `debugWebDriverLogs` or on fail (0 by default). 275 | * * `log_js_errors` - Set to true to include possible JavaScript to HTML report, or set to false (default) to deactivate. This will only work if `debug_log_entries` is set and its value is > 0. Also this will display JS errors as comments only if test fails. 276 | * * `webdriver_proxy` - sets http proxy to tunnel requests to the remote Selenium WebDriver through 277 | * * `webdriver_proxy_port` - sets http proxy server port to tunnel requests to the remote Selenium WebDriver through 278 | * 279 | * Example (`Acceptance.suite.yml`) 280 | * 281 | * ```yaml 282 | * modules: 283 | * enabled: 284 | * - WebDriver: 285 | * url: 'http://localhost/' 286 | * browser: firefox 287 | * window_size: 1024x768 288 | * capabilities: 289 | * unhandledPromptBehaviour: 'accept' 290 | * moz:firefoxOptions: 291 | * profile: '~/firefox-profiles/codeception-profile.zip.b64' 292 | * ``` 293 | * 294 | * ## Loading Parts from other Modules 295 | * 296 | * While all Codeception modules are designed to work stand-alone, it's still possible to load *several* modules at once. To use e.g. the [Asserts module](https://codeception.com/docs/modules/Asserts) in your acceptance tests, just load it like this in your `acceptance.suite.yml`: 297 | * 298 | * ```yaml 299 | * modules: 300 | * enabled: 301 | * - WebDriver 302 | * - Asserts 303 | * ``` 304 | * 305 | * However, when loading a framework module (e.g. [Symfony](https://codeception.com/docs/modules/Symfony)) like this, it would lead to a conflict: When you call `$I->amOnPage()`, Codeception wouldn't know if you want to access the page using WebDriver's `amOnPage()`, or Symfony's `amOnPage()`. That's why possibly conflicting modules are separated into "parts". Here's how to load just the "services" part from e.g. Symfony: 306 | * ```yaml 307 | * modules: 308 | * enabled: 309 | * - WebDriver 310 | * - Symfony: 311 | * part: services 312 | * ``` 313 | * To find out which parts each module has, look at the "Parts" header on the module's page. 314 | * 315 | * ## Usage 316 | * 317 | * ### Locating Elements 318 | * 319 | * Most methods in this module that operate on a DOM element (e.g. `click`) accept a locator as the first argument, 320 | * which can be either a string or an array. 321 | * 322 | * If the locator is an array, it should have a single element, 323 | * with the key signifying the locator type (`id`, `name`, `css`, `xpath`, `link`, or `class`) 324 | * and the value being the locator itself. 325 | * This is called a "strict" locator. 326 | * Examples: 327 | * 328 | * * `['id' => 'foo']` matches `
` 329 | * * `['name' => 'foo']` matches `
` 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 | *
2354 | * Login: 2355 | *
2356 | * Password: 2357 | *
2358 | * Do you agree to our terms? 2359 | *
2360 | * Select pricing plan: 2361 | * 2365 | * 2366 | *
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 | *