├── .github └── workflows │ └── main.yml ├── LICENSE ├── composer.json └── src └── Codeception ├── Constraint ├── Crawler.php └── CrawlerNot.php ├── Exception └── ExternalUrlException.php ├── Lib ├── Framework.php └── InnerBrowser.php └── Util └── HttpCode.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | COMPOSER_ROOT_VERSION: 4.99.99 11 | 12 | strategy: 13 | matrix: 14 | php: [8.1, 8.2, 8.3, 8.4] 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | 25 | - name: Validate composer.json and composer.lock 26 | run: composer validate 27 | 28 | - name: Install dependencies 29 | run: composer install --prefer-dist --no-progress --no-interaction --no-suggest 30 | 31 | - name: Run test suite 32 | run: php vendor/bin/codecept run 33 | -------------------------------------------------------------------------------- /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/lib-innerbrowser", 3 | "description": "Parent library for all Codeception framework modules and PhpBrowser", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "codeception" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "Michael Bodnarchuk", 12 | "email": "davert@mail.ua", 13 | "homepage": "https://codegyre.com" 14 | }, 15 | { 16 | "name": "Gintautas Miselis" 17 | } 18 | ], 19 | "homepage": "https://codeception.com/", 20 | "require": { 21 | "php": "^8.1", 22 | "ext-dom": "*", 23 | "ext-json": "*", 24 | "ext-mbstring": "*", 25 | "codeception/codeception": "^5.0.8", 26 | "codeception/lib-web": "^1.0.1", 27 | "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", 28 | "symfony/browser-kit": "^4.4.24 || ^5.4 || ^6.0 || ^7.0", 29 | "symfony/dom-crawler": "^4.4.30 || ^5.4 || ^6.0 || ^7.0" 30 | }, 31 | "require-dev": { 32 | "codeception/util-universalframework": "^1.0" 33 | }, 34 | "minimum-stability": "RC", 35 | "autoload": { 36 | "classmap": [ 37 | "src/" 38 | ] 39 | }, 40 | "config": { 41 | "classmap-authoritative": true, 42 | "sort-packages": true 43 | }, 44 | "scripts": { 45 | "test": "codecept run --coverage-xml" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Codeception/Constraint/Crawler.php: -------------------------------------------------------------------------------- 1 | count()) { 26 | return false; 27 | } 28 | if ($this->string === '') { 29 | return true; 30 | } 31 | 32 | foreach ($nodes as $node) { 33 | if (parent::matches($node->nodeValue)) { 34 | return true; 35 | } 36 | } 37 | return false; 38 | } 39 | 40 | /** 41 | * @param SymfonyDomCrawler $nodes 42 | * @param string $selector 43 | * @param ComparisonFailure|null $comparisonFailure 44 | */ 45 | protected function fail($nodes, $selector, ?ComparisonFailure $comparisonFailure = null): never 46 | { 47 | if (!$nodes->count()) { 48 | throw new ElementNotFound($selector, 'Element located either by name, CSS or XPath'); 49 | } 50 | 51 | $output = "Failed asserting that any element by '{$selector}' "; 52 | $output .= $this->uriMessage('on page'); 53 | 54 | if ($nodes->count() < 10) { 55 | $output .= $this->nodesList($nodes); 56 | } else { 57 | $output = sprintf('%s [total %d elements]', rtrim($output, ' '), $nodes->count()); 58 | } 59 | $output .= "\ncontains text '{$this->string}'"; 60 | 61 | throw new ExpectationFailedException( 62 | $output, 63 | $comparisonFailure 64 | ); 65 | } 66 | 67 | /** 68 | * @param DOMElement[] $other 69 | * @return string 70 | */ 71 | protected function failureDescription($other): string 72 | { 73 | $description = ''; 74 | foreach ($other as $o) { 75 | $description .= parent::failureDescription($o->textContent); 76 | } 77 | return $description; 78 | } 79 | 80 | protected function nodesList(SymfonyDomCrawler $domCrawler, ?string $contains = null): string 81 | { 82 | $output = ''; 83 | foreach ($domCrawler as $node) { 84 | if ($contains && strpos($node->nodeValue, $contains) === false) { 85 | continue; 86 | } 87 | $output .= "\n+ " . $node->C14N(); 88 | } 89 | return $output; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Codeception/Constraint/CrawlerNot.php: -------------------------------------------------------------------------------- 1 | string) { 30 | throw new ExpectationFailedException( 31 | "Element '{$selector}' was found", 32 | $comparisonFailure 33 | ); 34 | } 35 | 36 | $output = "There was '{$selector}' element "; 37 | $output .= $this->uriMessage('on page'); 38 | $output .= $this->nodesList($nodes, $this->string); 39 | $output .= "\ncontaining '{$this->string}'"; 40 | 41 | throw new ExpectationFailedException( 42 | $output, 43 | $comparisonFailure 44 | ); 45 | } 46 | 47 | public function toString(): string 48 | { 49 | if ($this->string) { 50 | return 'that contains text "' . $this->string . '"'; 51 | } 52 | return ''; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Codeception/Exception/ExternalUrlException.php: -------------------------------------------------------------------------------- 1 | internalDomains = null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Codeception/Lib/InnerBrowser.php: -------------------------------------------------------------------------------- 1 | |array|array 64 | */ 65 | protected array $defaultCookieParameters = ['expires' => null, 'path' => '/', 'domain' => '', 'secure' => false]; 66 | 67 | /** 68 | * @var string[]|null 69 | */ 70 | protected ?array $internalDomains = null; 71 | 72 | private ?string $baseUrl = null; 73 | 74 | public function _failed(TestInterface $test, $fail) 75 | { 76 | try { 77 | if (!$this->client || !$this->client->getInternalResponse()) { 78 | return; 79 | } 80 | } catch (BadMethodCallException) { 81 | //Symfony 5 throws exception if request() method threw an exception. 82 | //The "request()" method must be called before "Symfony\Component\BrowserKit\AbstractBrowser::getInternalResponse()" 83 | return; 84 | } 85 | $filename = preg_replace('#\W#', '.', Descriptor::getTestSignatureUnique($test)); 86 | 87 | $extensions = [ 88 | 'application/json' => 'json', 89 | 'text/xml' => 'xml', 90 | 'application/xml' => 'xml', 91 | 'text/plain' => 'txt' 92 | ]; 93 | 94 | try { 95 | $internalResponse = $this->client->getInternalResponse(); 96 | } catch (BadMethodCallException) { 97 | $internalResponse = false; 98 | } 99 | 100 | $responseContentType = $internalResponse ? (string) $internalResponse->getHeader('content-type') : ''; 101 | [$responseMimeType] = explode(';', $responseContentType); 102 | 103 | $extension = $extensions[$responseMimeType] ?? 'html'; 104 | 105 | $filename = mb_strcut($filename, 0, 244, 'utf-8') . '.fail.' . $extension; 106 | $this->_savePageSource($report = codecept_output_dir() . $filename); 107 | $test->getMetadata()->addReport('html', $report); 108 | $test->getMetadata()->addReport('response', $report); 109 | } 110 | 111 | public function _after(TestInterface $test) 112 | { 113 | $this->client = null; 114 | $this->crawler = null; 115 | $this->forms = []; 116 | $this->headers = []; 117 | } 118 | 119 | /** 120 | * @return class-string 121 | */ 122 | public function _conflicts(): string 123 | { 124 | return \Codeception\Lib\Interfaces\Web::class; 125 | } 126 | 127 | public function _findElements(mixed $locator): iterable 128 | { 129 | return $this->match($locator); 130 | } 131 | 132 | /** 133 | * Send custom request to a backend using method, uri, parameters, etc. 134 | * Use it in Helpers to create special request actions, like accessing API 135 | * Returns a string with response body. 136 | * 137 | * ```php 138 | * getModule('{{MODULE_NAME}}')->_request('POST', '/api/v1/users', ['name' => $name]); 142 | * $user = json_decode($userData); 143 | * return $user->id; 144 | * } 145 | * ``` 146 | * Does not load the response into the module so you can't interact with response page (click, fill forms). 147 | * To load arbitrary page for interaction, use `_loadPage` method. 148 | * 149 | * @throws ExternalUrlException|ModuleException 150 | * @api 151 | * @see `_loadPage` 152 | */ 153 | public function _request( 154 | string $method, 155 | string $uri, 156 | array $parameters = [], 157 | array $files = [], 158 | array $server = [], 159 | ?string $content = null 160 | ): ?string { 161 | $this->clientRequest($method, $uri, $parameters, $files, $server, $content); 162 | return $this->_getResponseContent(); 163 | } 164 | 165 | /** 166 | * Returns content of the last response 167 | * Use it in Helpers when you want to retrieve response of request performed by another module. 168 | * 169 | * ```php 170 | * assertStringContainsString($text, $this->getModule('{{MODULE_NAME}}')->_getResponseContent(), "response contains"); 175 | * } 176 | * ``` 177 | * 178 | * @api 179 | * @throws ModuleException 180 | */ 181 | public function _getResponseContent(): string 182 | { 183 | return $this->getRunningClient()->getInternalResponse()->getContent(); 184 | } 185 | 186 | protected function clientRequest( 187 | string $method, 188 | string $uri, 189 | array $parameters = [], 190 | array $files = [], 191 | array $server = [], 192 | ?string $content = null, 193 | bool $changeHistory = true 194 | ): SymfonyCrawler { 195 | $this->debugSection("Request Headers", $this->headers); 196 | 197 | foreach ($this->headers as $header => $val) { // moved from REST module 198 | 199 | if ($val === null || $val === '') { 200 | continue; 201 | } 202 | 203 | $header = str_replace('-', '_', strtoupper($header)); 204 | $server["HTTP_{$header}"] = $val; 205 | 206 | // Issue #827 - symfony foundation requires 'CONTENT_TYPE' without HTTP_ 207 | if ($this instanceof Framework && $header === 'CONTENT_TYPE') { 208 | $server[$header] = $val; 209 | } 210 | } 211 | 212 | $server['REQUEST_TIME'] = time(); 213 | $server['REQUEST_TIME_FLOAT'] = microtime(true); 214 | if ($this instanceof Framework) { 215 | if (preg_match('#^(//|https?://(?!localhost))#', $uri)) { 216 | $hostname = parse_url($uri, PHP_URL_HOST); 217 | if (!$this->isInternalDomain($hostname)) { 218 | throw new ExternalUrlException($this::class . " can't open external URL: " . $uri); 219 | } 220 | } 221 | 222 | if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) && $content === null && !empty($parameters)) { 223 | $content = http_build_query($parameters); 224 | } 225 | } 226 | 227 | if (method_exists($this->client, 'isFollowingRedirects')) { 228 | $isFollowingRedirects = $this->client->isFollowingRedirects(); 229 | $maxRedirects = $this->client->getMaxRedirects(); 230 | } else { 231 | //Symfony 2.7 support 232 | $isFollowingRedirects = ReflectionHelper::readPrivateProperty($this->client, 'followRedirects', 'Symfony\Component\BrowserKit\Client'); 233 | $maxRedirects = ReflectionHelper::readPrivateProperty($this->client, 'maxRedirects', 'Symfony\Component\BrowserKit\Client'); 234 | } 235 | 236 | if (!$isFollowingRedirects) { 237 | $result = $this->client->request($method, $uri, $parameters, $files, $server, $content, $changeHistory); 238 | $this->debugResponse($uri); 239 | return $result; 240 | } 241 | 242 | $this->client->followRedirects(false); 243 | $result = $this->client->request($method, $uri, $parameters, $files, $server, $content, $changeHistory); 244 | $this->debugResponse($uri); 245 | return $this->redirectIfNecessary($result, $maxRedirects, 0); 246 | } 247 | 248 | protected function isInternalDomain(string $domain): bool 249 | { 250 | if ($this->internalDomains === null) { 251 | $this->internalDomains = $this->getInternalDomains(); 252 | } 253 | 254 | foreach ($this->internalDomains as $pattern) { 255 | if (preg_match($pattern, $domain)) { 256 | return true; 257 | } 258 | } 259 | 260 | return false; 261 | } 262 | 263 | /** 264 | * Opens a page with arbitrary request parameters. 265 | * Useful for testing multi-step forms on a specific step. 266 | * 267 | * ```php 268 | * getModule('{{MODULE_NAME}}')->_loadPage('POST', '/checkout/step2', ['order' => $orderId]); 272 | * } 273 | * ``` 274 | * 275 | * @api 276 | */ 277 | public function _loadPage( 278 | string $method, 279 | string $uri, 280 | array $parameters = [], 281 | array $files = [], 282 | array $server = [], 283 | ?string $content = null 284 | ): void { 285 | $this->crawler = $this->clientRequest($method, $uri, $parameters, $files, $server, $content); 286 | $this->baseUrl = $this->retrieveBaseUrl(); 287 | $this->forms = []; 288 | } 289 | 290 | /** 291 | * @throws ModuleException 292 | */ 293 | private function getCrawler(): SymfonyCrawler 294 | { 295 | if (!$this->crawler) { 296 | throw new ModuleException($this, 'Crawler is null. Perhaps you forgot to call "amOnPage"?'); 297 | } 298 | 299 | return $this->crawler; 300 | } 301 | 302 | private function getRunningClient(): AbstractBrowser 303 | { 304 | try { 305 | if ($this->client->getInternalRequest() === null) { 306 | throw new ModuleException( 307 | $this, 308 | "Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it" 309 | ); 310 | } 311 | } catch (BadMethodCallException) { 312 | //Symfony 5 313 | throw new ModuleException( 314 | $this, 315 | "Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it" 316 | ); 317 | } 318 | 319 | return $this->client; 320 | } 321 | 322 | public function _savePageSource(string $filename): void 323 | { 324 | file_put_contents($filename, $this->_getResponseContent()); 325 | } 326 | 327 | /** 328 | * Authenticates user for HTTP_AUTH 329 | */ 330 | public function amHttpAuthenticated(string $username, string $password): void 331 | { 332 | $this->client->setServerParameter('PHP_AUTH_USER', $username); 333 | $this->client->setServerParameter('PHP_AUTH_PW', $password); 334 | } 335 | 336 | /** 337 | * Sets the HTTP header to the passed value - which is used on 338 | * subsequent HTTP requests through PhpBrowser. 339 | * 340 | * Example: 341 | * ```php 342 | * haveHttpHeader('X-Requested-With', 'Codeception'); 344 | * $I->amOnPage('test-headers.php'); 345 | * ``` 346 | * 347 | * To use special chars in Header Key use HTML Character Entities: 348 | * Example: 349 | * Header with underscore - 'Client_Id' 350 | * should be represented as - 'Client_Id' or 'Client_Id' 351 | * 352 | * ```php 353 | * haveHttpHeader('Client_Id', 'Codeception'); 355 | * ``` 356 | * 357 | * @param string $name the name of the request header 358 | * @param string $value the value to set it to for subsequent 359 | * requests 360 | */ 361 | public function haveHttpHeader(string $name, string $value): void 362 | { 363 | $name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name))))); 364 | $this->headers[$name] = $value; 365 | } 366 | 367 | /** 368 | * Unsets a HTTP header (that was originally added by [haveHttpHeader()](#haveHttpHeader)), 369 | * so that subsequent requests will not send it anymore. 370 | * 371 | * Example: 372 | * ```php 373 | * haveHttpHeader('X-Requested-With', 'Codeception'); 375 | * $I->amOnPage('test-headers.php'); 376 | * // ... 377 | * $I->unsetHeader('X-Requested-With'); 378 | * $I->amOnPage('some-other-page.php'); 379 | * ``` 380 | * 381 | * @param string $name the name of the header to unset. 382 | */ 383 | public function unsetHttpHeader(string $name): void 384 | { 385 | $name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name))))); 386 | unset($this->headers[$name]); 387 | } 388 | 389 | /** 390 | * @deprecated Use [unsetHttpHeader](#unsetHttpHeader) instead 391 | */ 392 | public function deleteHeader(string $name): void 393 | { 394 | $this->unsetHttpHeader($name); 395 | } 396 | 397 | public function amOnPage(string $page): void 398 | { 399 | $this->_loadPage('GET', $page); 400 | } 401 | 402 | public function click($link, $context = null): void 403 | { 404 | if ($context) { 405 | $this->crawler = $this->match($context); 406 | } 407 | 408 | if (is_array($link)) { 409 | $this->clickByLocator($link); 410 | return; 411 | } 412 | 413 | $anchor = $this->strictMatch(['link' => $link]); 414 | if (count($anchor) === 0) { 415 | $anchor = $this->getCrawler()->selectLink($link); 416 | } 417 | 418 | if (count($anchor) > 0) { 419 | $this->openHrefFromDomNode($anchor->getNode(0)); 420 | return; 421 | } 422 | 423 | $buttonText = str_replace('"', "'", $link); 424 | $button = $this->crawler->selectButton($buttonText); 425 | 426 | if (count($button) && $this->clickButton($button->getNode(0))) { 427 | return; 428 | } 429 | 430 | try { 431 | $this->clickByLocator($link); 432 | } catch (MalformedLocatorException) { 433 | throw new ElementNotFound("name={$link}", "'{$link}' is invalid CSS and XPath selector and Link or Button"); 434 | } 435 | } 436 | 437 | /** 438 | * @param string|string[] $link 439 | */ 440 | protected function clickByLocator(string|array $link): ?bool 441 | { 442 | $nodes = $this->match($link); 443 | if ($nodes->count() === 0) { 444 | throw new ElementNotFound($link, 'Link or Button by name or CSS or XPath'); 445 | } 446 | 447 | foreach ($nodes as $node) { 448 | $tag = $node->tagName; 449 | $type = $node->getAttribute('type'); 450 | 451 | if ($tag === 'a') { 452 | $this->openHrefFromDomNode($node); 453 | return true; 454 | } 455 | 456 | if (in_array($tag, ['input', 'button']) && in_array($type, ['submit', 'image'])) { 457 | return $this->clickButton($node); 458 | } 459 | } 460 | 461 | return null; 462 | } 463 | 464 | /** 465 | * Clicks the link or submits the form when the button is clicked 466 | * 467 | * @return bool clicked something 468 | */ 469 | private function clickButton(DOMNode $node): bool 470 | { 471 | /** 472 | * First we check if the button is associated to a form. 473 | * It is associated to a form when it has a nonempty form 474 | */ 475 | $formAttribute = $node->attributes->getNamedItem('form'); 476 | if (isset($formAttribute)) { 477 | $form = empty($formAttribute->nodeValue) ? null : $this->filterByCSS('#' . $formAttribute->nodeValue)->getNode(0); 478 | } else { 479 | // Check parents 480 | $currentNode = $node; 481 | $form = null; 482 | while ($currentNode->parentNode !== null) { 483 | $currentNode = $currentNode->parentNode; 484 | if ($currentNode->nodeName === 'form') { 485 | $form = $node; 486 | break; 487 | } 488 | } 489 | } 490 | 491 | if (isset($form)) { 492 | $buttonName = $node->getAttribute('name'); 493 | $formParams = $buttonName !== '' ? [$buttonName => $node->getAttribute('value')] : []; 494 | $this->proceedSubmitForm( 495 | new SymfonyCrawler($form, $this->getAbsoluteUrlFor($this->_getCurrentUri()), $this->getBaseUrl()), 496 | $formParams 497 | ); 498 | return true; 499 | } 500 | 501 | // Check if the button is inside an anchor. 502 | $currentNode = $node; 503 | while ($currentNode->parentNode !== null) { 504 | $currentNode = $currentNode->parentNode; 505 | if ($currentNode->nodeName === 'a') { 506 | $this->openHrefFromDomNode($currentNode); 507 | return true; 508 | } 509 | } 510 | 511 | throw new TestRuntimeException('Button is not inside a link or a form'); 512 | } 513 | 514 | private function openHrefFromDomNode(DOMNode $node): void 515 | { 516 | $link = new Link($node, $this->getBaseUrl()); 517 | $this->amOnPage(preg_replace('/#.*/', '', $link->getUri())); 518 | } 519 | 520 | private function getBaseUrl(): ?string 521 | { 522 | return $this->baseUrl; 523 | } 524 | 525 | private function retrieveBaseUrl(): string 526 | { 527 | $baseUrl = ''; 528 | 529 | $baseHref = $this->crawler->filter('base'); 530 | if (count($baseHref) > 0) { 531 | $baseUrl = $baseHref->getNode(0)->getAttribute('href'); 532 | } 533 | 534 | if ($baseUrl === '') { 535 | $baseUrl = $this->_getCurrentUri(); 536 | } 537 | 538 | return $this->getAbsoluteUrlFor($baseUrl); 539 | } 540 | 541 | public function see(string $text, $selector = null): void 542 | { 543 | if (!$selector) { 544 | $this->assertPageContains($text); 545 | return; 546 | } 547 | 548 | $nodes = $this->match($selector); 549 | $this->assertDomContains($nodes, $this->stringifySelector($selector), $text); 550 | } 551 | 552 | public function dontSee(string $text, $selector = null): void 553 | { 554 | if (!$selector) { 555 | $this->assertPageNotContains($text); 556 | return; 557 | } 558 | 559 | $nodes = $this->match($selector); 560 | $this->assertDomNotContains($nodes, $this->stringifySelector($selector), $text); 561 | } 562 | 563 | public function seeInSource(string $raw): void 564 | { 565 | $this->assertPageSourceContains($raw); 566 | } 567 | 568 | public function dontSeeInSource(string $raw): void 569 | { 570 | $this->assertPageSourceNotContains($raw); 571 | } 572 | 573 | public function seeLink(string $text, ?string $url = null): void 574 | { 575 | $crawler = $this->getCrawler()->selectLink($text); 576 | if ($crawler->count() === 0) { 577 | $this->fail("No links containing text '{$text}' were found in page " . $this->_getCurrentUri()); 578 | } 579 | 580 | if ($url) { 581 | $crawler = $crawler->filterXPath(sprintf('.//a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', SymfonyCrawler::xpathLiteral($url))); 582 | if ($crawler->count() === 0) { 583 | $this->fail("No links containing text '{$text}' and URL '{$url}' were found in page " . $this->_getCurrentUri()); 584 | } 585 | } 586 | 587 | $this->assertTrue(true); 588 | } 589 | 590 | public function dontSeeLink(string $text, string $url = ''): void 591 | { 592 | $crawler = $this->getCrawler()->selectLink($text); 593 | if (!$url && $crawler->count() > 0) { 594 | $this->fail("Link containing text '{$text}' was found in page " . $this->_getCurrentUri()); 595 | } 596 | 597 | $crawler = $crawler->filterXPath( 598 | sprintf('.//a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', 599 | SymfonyCrawler::xpathLiteral((string) $url)) 600 | ); 601 | if ($crawler->count() > 0) { 602 | $this->fail("Link containing text '{$text}' and URL '{$url}' was found in page " . $this->_getCurrentUri()); 603 | } 604 | } 605 | 606 | /** 607 | * @throws ModuleException 608 | */ 609 | public function _getCurrentUri(): string 610 | { 611 | return Uri::retrieveUri($this->getRunningClient()->getHistory()->current()->getUri()); 612 | } 613 | 614 | public function seeInCurrentUrl(string $uri): void 615 | { 616 | $this->assertStringContainsString($uri, $this->_getCurrentUri()); 617 | } 618 | 619 | public function dontSeeInCurrentUrl(string $uri): void 620 | { 621 | $this->assertStringNotContainsString($uri, $this->_getCurrentUri()); 622 | } 623 | 624 | public function seeCurrentUrlEquals(string $uri): void 625 | { 626 | $this->assertSame(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/')); 627 | } 628 | 629 | public function dontSeeCurrentUrlEquals(string $uri): void 630 | { 631 | $this->assertNotSame(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/')); 632 | } 633 | 634 | public function seeCurrentUrlMatches(string $uri): void 635 | { 636 | $this->assertRegExp($uri, $this->_getCurrentUri()); 637 | } 638 | 639 | public function dontSeeCurrentUrlMatches(string $uri): void 640 | { 641 | $this->assertNotRegExp($uri, $this->_getCurrentUri()); 642 | } 643 | 644 | public function grabFromCurrentUrl(?string $uri = null): mixed 645 | { 646 | if (!$uri) { 647 | return $this->_getCurrentUri(); 648 | } 649 | 650 | $matches = []; 651 | $res = preg_match($uri, $this->_getCurrentUri(), $matches); 652 | if (!$res) { 653 | $this->fail("Couldn't match {$uri} in " . $this->_getCurrentUri()); 654 | } 655 | 656 | if (!isset($matches[1])) { 657 | $this->fail("Nothing to grab. A regex parameter required. Ex: '/user/(\\d+)'"); 658 | } 659 | 660 | return $matches[1]; 661 | } 662 | 663 | public function seeCheckboxIsChecked($checkbox): void 664 | { 665 | $checkboxes = $this->getFieldsByLabelOrCss($checkbox); 666 | $this->assertGreaterThan(0, $checkboxes->filter('input[checked]')->count()); 667 | } 668 | 669 | public function dontSeeCheckboxIsChecked($checkbox): void 670 | { 671 | $checkboxes = $this->getFieldsByLabelOrCss($checkbox); 672 | $this->assertSame(0, $checkboxes->filter('input[checked]')->count()); 673 | } 674 | 675 | public function seeInField($field, $value): void 676 | { 677 | $nodes = $this->getFieldsByLabelOrCss($field); 678 | $this->assert($this->proceedSeeInField($nodes, $value)); 679 | } 680 | 681 | public function dontSeeInField($field, $value): void 682 | { 683 | $nodes = $this->getFieldsByLabelOrCss($field); 684 | $this->assertNot($this->proceedSeeInField($nodes, $value)); 685 | } 686 | 687 | public function seeInFormFields($formSelector, array $params): void 688 | { 689 | $this->proceedSeeInFormFields($formSelector, $params, false); 690 | } 691 | 692 | public function dontSeeInFormFields($formSelector, array $params): void 693 | { 694 | $this->proceedSeeInFormFields($formSelector, $params, true); 695 | } 696 | 697 | protected function proceedSeeInFormFields($formSelector, array $params, $assertNot) 698 | { 699 | $form = $this->match($formSelector)->first(); 700 | if ($form->count() === 0) { 701 | throw new ElementNotFound($formSelector, 'Form'); 702 | } 703 | 704 | $fields = []; 705 | foreach ($params as $name => $values) { 706 | $this->pushFormField($fields, $form, $name, $values); 707 | } 708 | 709 | foreach ($fields as [$field, $values]) { 710 | if (!is_array($values)) { 711 | $values = [$values]; 712 | } 713 | 714 | foreach ($values as $value) { 715 | $ret = $this->proceedSeeInField($field, $value); 716 | if ($assertNot) { 717 | $this->assertNot($ret); 718 | } else { 719 | $this->assert($ret); 720 | } 721 | } 722 | } 723 | } 724 | 725 | /** 726 | * Map an array element passed to seeInFormFields to its corresponding field, 727 | * recursing through array values if the field is not found. 728 | * 729 | * @param array $fields The previously found fields. 730 | * @param SymfonyCrawler $form The form in which to search for fields. 731 | * @param string $name The field's name. 732 | * @param mixed $values 733 | */ 734 | protected function pushFormField(array &$fields, SymfonyCrawler $form, string $name, $values): void 735 | { 736 | $field = $form->filterXPath(sprintf('.//*[@name=%s]', SymfonyCrawler::xpathLiteral($name))); 737 | 738 | if ($field->count() !== 0) { 739 | $fields[] = [$field, $values]; 740 | } elseif (is_array($values)) { 741 | foreach ($values as $key => $value) { 742 | $this->pushFormField($fields, $form, sprintf('%s[%s]', $name, $key), $value); 743 | } 744 | } else { 745 | throw new ElementNotFound( 746 | sprintf('//*[@name=%s]', SymfonyCrawler::xpathLiteral($name)), 747 | 'Form' 748 | ); 749 | } 750 | } 751 | 752 | protected function proceedSeeInField(Crawler $fields, $value): array 753 | { 754 | $testValues = $this->getValueAndTextFromField($fields); 755 | if (!is_array($testValues)) { 756 | $testValues = [$testValues]; 757 | } 758 | 759 | if (is_bool($value) && $value && !empty($testValues)) { 760 | $value = reset($testValues); 761 | } elseif (empty($testValues)) { 762 | $testValues = ['']; 763 | } 764 | 765 | return [ 766 | 'Contains', 767 | (string)$value, 768 | $testValues, 769 | sprintf( 770 | "Failed asserting that `%s` is in %s's value: %s", 771 | $value, 772 | $fields->getNode(0)->nodeName, 773 | var_export($testValues, true) 774 | ) 775 | ]; 776 | } 777 | 778 | /** 779 | * Get the values of a set of fields and also the texts of selected options. 780 | */ 781 | protected function getValueAndTextFromField(Crawler $nodes): array|string 782 | { 783 | if ($nodes->filter('textarea')->count() !== 0) { 784 | return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue(); 785 | } 786 | 787 | $input = $nodes->filter('input'); 788 | if ($input->count() !== 0) { 789 | return $this->getInputValue($input); 790 | } 791 | 792 | if ($nodes->filter('select')->count() !== 0) { 793 | $options = $nodes->filter('option[selected]'); 794 | $values = []; 795 | 796 | foreach ($options as $option) { 797 | $values[] = $option->getAttribute('value'); 798 | $values[] = $option->textContent; 799 | $values[] = trim($option->textContent); 800 | } 801 | 802 | return $values; 803 | } 804 | 805 | $this->fail("Element {$nodes} is not a form field or does not contain a form field"); 806 | } 807 | 808 | /** 809 | * Get the values of a set of input fields. 810 | */ 811 | protected function getInputValue(SymfonyCrawler $input): array|string 812 | { 813 | $inputType = $input->attr('type'); 814 | if ($inputType === 'checkbox' || $inputType === 'radio') { 815 | $values = []; 816 | 817 | foreach ($input->filter(':checked') as $checkbox) { 818 | $values[] = $checkbox->getAttribute('value'); 819 | } 820 | 821 | return $values; 822 | } 823 | 824 | return (new InputFormField($input->getNode(0)))->getValue(); 825 | } 826 | 827 | /** 828 | * Strips out one pair of trailing square brackets from a field's 829 | * name. 830 | * 831 | * @param string $name the field name 832 | * @return string the name after stripping trailing square brackets 833 | */ 834 | protected function getSubmissionFormFieldName(string $name): string 835 | { 836 | if (str_ends_with($name, '[]')) { 837 | return substr($name, 0, -2); 838 | } 839 | 840 | return $name; 841 | } 842 | 843 | /** 844 | * Replaces boolean values in $params with the corresponding field's 845 | * value for checkbox form fields. 846 | * 847 | * The function loops over all input checkbox fields, checking if a 848 | * corresponding key is set in $params. If it is, and the value is 849 | * boolean or an array containing booleans, the value(s) are 850 | * replaced in the array with the real value of the checkbox, and 851 | * the array is returned. 852 | * 853 | * @param SymfonyCrawler $form the form to find checkbox elements 854 | * @param array $params the parameters to be submitted 855 | * @return array the $params array after replacing bool values 856 | */ 857 | protected function setCheckboxBoolValues(Crawler $form, array $params): array 858 | { 859 | $checkboxes = $form->filter('input[type=checkbox]'); 860 | $chFoundByName = []; 861 | foreach ($checkboxes as $checkbox) { 862 | $fieldName = $this->getSubmissionFormFieldName($checkbox->getAttribute('name')); 863 | $pos = $chFoundByName[$fieldName] ?? 0; 864 | $skip = !isset($params[$fieldName]) 865 | || (!is_array($params[$fieldName]) && !is_bool($params[$fieldName])) 866 | || (is_array($params[$fieldName]) && 867 | ($pos >= count($params[$fieldName]) || !is_bool($params[$fieldName][$pos])) 868 | ); 869 | 870 | if ($skip) { 871 | continue; 872 | } 873 | 874 | $values = $params[$fieldName]; 875 | if ($values === true) { 876 | $params[$fieldName] = $checkbox->hasAttribute('value') ? $checkbox->getAttribute('value') : 'on'; 877 | $chFoundByName[$fieldName] = $pos + 1; 878 | } elseif (is_array($values)) { 879 | if ($values[$pos] === true) { 880 | $params[$fieldName][$pos] = $checkbox->hasAttribute('value') ? $checkbox->getAttribute('value') : 'on'; 881 | $chFoundByName[$fieldName] = $pos + 1; 882 | } else { 883 | array_splice($params[$fieldName], $pos, 1); 884 | } 885 | } else { 886 | unset($params[$fieldName]); 887 | } 888 | } 889 | 890 | return $params; 891 | } 892 | 893 | /** 894 | * Submits the form currently selected in the passed SymfonyCrawler, after 895 | * setting any values passed in $params and setting the value of the 896 | * passed button name. 897 | * 898 | * @param SymfonyCrawler $frmCrawl the form to submit 899 | * @param array $params additional parameter values to set on the 900 | * form 901 | * @param string|null $button the name of a submit button in the form 902 | */ 903 | protected function proceedSubmitForm(Crawler $frmCrawl, array $params, ?string $button = null): void 904 | { 905 | $url = null; 906 | $form = $this->getFormFor($frmCrawl); 907 | $defaults = $this->getFormValuesFor($form); 908 | $merged = array_merge($defaults, $params); 909 | $requestParams = $this->setCheckboxBoolValues($frmCrawl, $merged); 910 | 911 | if (!empty($button)) { 912 | $btnCrawl = $frmCrawl->filterXPath(sprintf( 913 | '//*[not(@disabled) and @type="submit" and @name=%s]', 914 | SymfonyCrawler::xpathLiteral($button) 915 | )); 916 | if (count($btnCrawl) > 0) { 917 | $requestParams[$button] = $btnCrawl->attr('value'); 918 | $formaction = $btnCrawl->attr('formaction'); 919 | if ($formaction) { 920 | $url = $formaction; 921 | } 922 | } 923 | } 924 | 925 | if ($url === null) { 926 | $url = $this->getFormUrl($frmCrawl); 927 | } 928 | 929 | if (strcasecmp($form->getMethod(), 'GET') === 0) { 930 | $url = Uri::mergeUrls($url, '?' . http_build_query($requestParams)); 931 | } 932 | 933 | $url = preg_replace('#\#.*#', '', $url); 934 | 935 | $this->debugSection('Uri', $url); 936 | $this->debugSection('Method', $form->getMethod()); 937 | $this->debugSection('Parameters', $requestParams); 938 | 939 | $requestParams= $this->getFormPhpValues($requestParams); 940 | 941 | $this->crawler = $this->clientRequest( 942 | $form->getMethod(), 943 | $url, 944 | $requestParams, 945 | $form->getPhpFiles() 946 | ); 947 | $this->forms = []; 948 | } 949 | 950 | public function submitForm($selector, array $params, ?string $button = null): void 951 | { 952 | $form = $this->match($selector)->first(); 953 | if (count($form) === 0) { 954 | throw new ElementNotFound($this->stringifySelector($selector), 'Form'); 955 | } 956 | 957 | $this->proceedSubmitForm($form, $params, $button); 958 | } 959 | 960 | /** 961 | * Returns an absolute URL for the passed URI with the current URL 962 | * as the base path. 963 | * 964 | * @param string $uri the absolute or relative URI 965 | * @return string the absolute URL 966 | * @throws TestRuntimeException if either the current 967 | * URL or the passed URI can't be parsed 968 | */ 969 | protected function getAbsoluteUrlFor(string $uri): string 970 | { 971 | $currentUrl = $this->getRunningClient()->getHistory()->current()->getUri(); 972 | if (empty($uri) || str_starts_with($uri, '#')) { 973 | return $currentUrl; 974 | } 975 | 976 | return Uri::mergeUrls($currentUrl, $uri); 977 | } 978 | 979 | /** 980 | * Returns the form action's absolute URL. 981 | * 982 | * @throws TestRuntimeException if either the current 983 | * URL or the URI of the form's action can't be parsed 984 | */ 985 | protected function getFormUrl(Crawler $form): string 986 | { 987 | $action = $form->form()->getUri(); 988 | return $this->getAbsoluteUrlFor($action); 989 | } 990 | 991 | /** 992 | * Returns a crawler Form object for the form pointed to by the 993 | * passed SymfonyCrawler. 994 | * 995 | * The returned form is an independent Crawler created to take care 996 | * of the following issues currently experienced by Crawler's form 997 | * object: 998 | * - input fields disabled at a higher level (e.g. by a surrounding 999 | * fieldset) still return values 1000 | * - Codeception expects an empty value to match an unselected 1001 | * select box. 1002 | * 1003 | * The function clones the crawler's node and creates a new crawler 1004 | * because it destroys or adds to the DOM for the form to achieve 1005 | * the desired functionality. Other functions simply querying the 1006 | * DOM wouldn't expect them. 1007 | * 1008 | * @param SymfonyCrawler $form the form 1009 | */ 1010 | private function getFormFromCrawler(Crawler $form): SymfonyForm 1011 | { 1012 | $fakeDom = new DOMDocument(); 1013 | $fakeDom->appendChild($fakeDom->importNode($form->getNode(0), true)); 1014 | 1015 | //add fields having form attribute with id of this form 1016 | $formId = $form->attr('id'); 1017 | if ($formId !== null) { 1018 | $fakeForm = $fakeDom->firstChild; 1019 | $topParent = $this->getAncestorsFor($form)->last(); 1020 | $fieldsByFormAttribute = $topParent->filter( 1021 | sprintf('input[form=%s],select[form=%s],textarea[form=%s]', $formId, $formId, $formId) 1022 | ); 1023 | foreach ($fieldsByFormAttribute as $field) { 1024 | $fakeForm->appendChild($fakeDom->importNode($field, true)); 1025 | } 1026 | } 1027 | 1028 | $node = $fakeDom->documentElement; 1029 | $action = $this->getFormUrl($form); 1030 | $cloned = new SymfonyCrawler($node, $action, $this->getBaseUrl()); 1031 | $shouldDisable = $cloned->filter( 1032 | 'input:disabled:not([disabled]),select option:disabled,select optgroup:disabled option:not([disabled]),textarea:disabled:not([disabled]),select:disabled:not([disabled])' 1033 | ); 1034 | foreach ($shouldDisable as $field) { 1035 | $field->parentNode->removeChild($field); 1036 | } 1037 | 1038 | return $cloned->form(); 1039 | } 1040 | 1041 | /** 1042 | * Returns the DomCrawler\Form object for the form pointed to by 1043 | * $node or its closes form parent. 1044 | */ 1045 | protected function getFormFor(Crawler $node): SymfonyForm 1046 | { 1047 | if (strcasecmp($node->first()->getNode(0)->tagName, 'form') === 0) { 1048 | $form = $node->first(); 1049 | } else { 1050 | $form = $this->getAncestorsFor($node)->filter('form')->first(); 1051 | } 1052 | 1053 | if (!$form) { 1054 | $this->fail('The selected node is not a form and does not have a form ancestor.'); 1055 | } 1056 | 1057 | $identifier = $form->attr('id') ?: $form->attr('action'); 1058 | if (!isset($this->forms[$identifier])) { 1059 | $this->forms[$identifier] = $this->getFormFromCrawler($form); 1060 | } 1061 | 1062 | return $this->forms[$identifier]; 1063 | } 1064 | 1065 | /** 1066 | * Returns the ancestors of the passed SymfonyCrawler. 1067 | * 1068 | * symfony/dom-crawler deprecated parents() in favor of ancestors() 1069 | * This provides backward compatibility with < 5.3.0-BETA-1 1070 | * 1071 | * @param SymfonyCrawler $crawler the crawler 1072 | * @return SymfonyCrawler the ancestors 1073 | */ 1074 | private function getAncestorsFor(SymfonyCrawler $crawler): SymfonyCrawler 1075 | { 1076 | if (method_exists($crawler, 'ancestors')) { 1077 | return $crawler->ancestors(); 1078 | } 1079 | 1080 | return $crawler->parents(); 1081 | } 1082 | 1083 | /** 1084 | * Returns an array of name => value pairs for the passed form. 1085 | * 1086 | * For form fields containing a name ending in [], an array is 1087 | * created out of all field values with the given name. 1088 | * 1089 | * @param SymfonyForm $form the form 1090 | * @return array an array of name => value pairs 1091 | */ 1092 | protected function getFormValuesFor(SymfonyForm $form): array 1093 | { 1094 | $formNodeCrawler = new Crawler($form->getFormNode()); 1095 | $values = []; 1096 | $fields = $form->all(); 1097 | foreach ($fields as $field) { 1098 | if ($field instanceof FileFormField || $field->isDisabled()) { 1099 | continue; 1100 | } 1101 | 1102 | if (!$field->hasValue()) { 1103 | // if unchecked a checkbox and if there is hidden input with same name to submit unchecked value 1104 | $hiddenInput = $formNodeCrawler->filter('input[type=hidden][name="'.$field->getName().'"]:not([disabled])'); 1105 | if (count($hiddenInput) === 0) { 1106 | continue; 1107 | } else { 1108 | // there might be multiple hidden input with same name, but we will only grab last one's value 1109 | $fieldValue = $hiddenInput->last()->attr('value'); 1110 | } 1111 | } else { 1112 | $fieldValue = $field->getValue(); 1113 | } 1114 | 1115 | 1116 | $fieldName = $this->getSubmissionFormFieldName($field->getName()); 1117 | if (str_ends_with($field->getName(), '[]')) { 1118 | if (!isset($values[$fieldName])) { 1119 | $values[$fieldName] = []; 1120 | } 1121 | 1122 | $values[$fieldName][] = $fieldValue; 1123 | } else { 1124 | $values[$fieldName] = $fieldValue; 1125 | } 1126 | } 1127 | 1128 | return $values; 1129 | } 1130 | 1131 | public function fillField($field, $value): void 1132 | { 1133 | $value = (string) $value; 1134 | $input = $this->getFieldByLabelOrCss($field); 1135 | $form = $this->getFormFor($input); 1136 | $name = $input->attr('name'); 1137 | 1138 | $dynamicField = $input->getNode(0)->tagName === 'textarea' 1139 | ? new TextareaFormField($input->getNode(0)) 1140 | : new InputFormField($input->getNode(0)); 1141 | $formField = $this->matchFormField($name, $form, $dynamicField); 1142 | $formField->setValue($value); 1143 | $input->getNode(0)->setAttribute('value', htmlspecialchars($value)); 1144 | $inputGetNode = $input->getNode(0); 1145 | if ($inputGetNode->tagName === 'textarea') { 1146 | $input->getNode(0)->nodeValue = htmlspecialchars($value); 1147 | } 1148 | } 1149 | 1150 | protected function getFieldsByLabelOrCss($field): SymfonyCrawler 1151 | { 1152 | $input = null; 1153 | if (is_array($field)) { 1154 | $input = $this->strictMatch($field); 1155 | if (count($input) === 0) { 1156 | throw new ElementNotFound($field); 1157 | } 1158 | 1159 | return $input; 1160 | } 1161 | 1162 | // by label 1163 | $label = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]', SymfonyCrawler::xpathLiteral($field))]); 1164 | if (count($label) > 0) { 1165 | $label = $label->first(); 1166 | if ($label->attr('for')) { 1167 | $input = $this->strictMatch(['id' => $label->attr('for')]); 1168 | } else { 1169 | $input = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]//input', SymfonyCrawler::xpathLiteral($field))]); 1170 | } 1171 | } 1172 | 1173 | // by name 1174 | if (!isset($input)) { 1175 | $input = $this->strictMatch(['name' => $field]); 1176 | } 1177 | 1178 | // by CSS and XPath 1179 | if (count($input) === 0) { 1180 | $input = $this->match($field); 1181 | } 1182 | 1183 | if (count($input) === 0) { 1184 | throw new ElementNotFound($field, 'Form field by Label or CSS'); 1185 | } 1186 | 1187 | return $input; 1188 | } 1189 | 1190 | protected function getFieldByLabelOrCss($field): SymfonyCrawler 1191 | { 1192 | $input = $this->getFieldsByLabelOrCss($field); 1193 | return $input->first(); 1194 | } 1195 | 1196 | public function selectOption($select, $option): void 1197 | { 1198 | $field = $this->getFieldByLabelOrCss($select); 1199 | $form = $this->getFormFor($field); 1200 | $fieldName = $this->getSubmissionFormFieldName($field->attr('name')); 1201 | 1202 | if (is_array($option)) { 1203 | if (!isset($option[0])) { // strict option locator 1204 | $form[$fieldName]->select($this->matchOption($field, $option)); 1205 | codecept_debug($option); 1206 | return; 1207 | } 1208 | 1209 | $options = []; 1210 | foreach ($option as $opt) { 1211 | $options[] = $this->matchOption($field, $opt); 1212 | } 1213 | 1214 | $form[$fieldName]->select($options); 1215 | return; 1216 | } 1217 | 1218 | $dynamicField = new ChoiceFormField($field->getNode(0)); 1219 | $formField = $this->matchFormField($fieldName, $form, $dynamicField); 1220 | $selValue = $this->matchOption($field, $option); 1221 | 1222 | if (is_array($formField)) { 1223 | foreach ($formField as $field) { 1224 | $values = $field->availableOptionValues(); 1225 | foreach ($values as $val) { 1226 | if ($val === $option) { 1227 | $field->select($selValue); 1228 | return; 1229 | } 1230 | } 1231 | } 1232 | 1233 | return; 1234 | } 1235 | 1236 | $formField->select((string) $this->matchOption($field, $option)); 1237 | } 1238 | 1239 | /** 1240 | * @return mixed 1241 | */ 1242 | protected function matchOption(Crawler $field, string|array $option) 1243 | { 1244 | if (isset($option['value'])) { 1245 | return $option['value']; 1246 | } 1247 | 1248 | if (isset($option['text'])) { 1249 | $option = $option['text']; 1250 | } 1251 | 1252 | $options = $field->filterXPath(sprintf('//option[text()=normalize-space("%s")]|//input[@type="radio" and @value=normalize-space("%s")]', $option, $option)); 1253 | if ($options->count() !== 0) { 1254 | $firstMatchingDomNode = $options->getNode(0); 1255 | if ($firstMatchingDomNode->tagName === 'option') { 1256 | $firstMatchingDomNode->setAttribute('selected', 'selected'); 1257 | } else { 1258 | $firstMatchingDomNode->setAttribute('checked', 'checked'); 1259 | } 1260 | 1261 | $valueAttribute = $options->first()->attr('value'); 1262 | //attr() returns null when option has no value attribute 1263 | if ($valueAttribute !== null) { 1264 | return $valueAttribute; 1265 | } 1266 | 1267 | return $options->first()->text(); 1268 | } 1269 | 1270 | return $option; 1271 | } 1272 | 1273 | public function checkOption($option): void 1274 | { 1275 | $this->proceedCheckOption($option)->tick(); 1276 | } 1277 | 1278 | public function uncheckOption($option): void 1279 | { 1280 | $this->proceedCheckOption($option)->untick(); 1281 | } 1282 | 1283 | /** 1284 | * @param string|string[] $option 1285 | */ 1286 | protected function proceedCheckOption(string|array $option): ChoiceFormField 1287 | { 1288 | $form = $this->getFormFor($field = $this->getFieldByLabelOrCss($option)); 1289 | $name = $field->attr('name'); 1290 | 1291 | if ($field->getNode(0) === null) { 1292 | throw new TestRuntimeException("Form field {$name} is not located"); 1293 | } 1294 | 1295 | // If the name is an array than we compare objects to find right checkbox 1296 | $formField = $this->matchFormField($name, $form, new ChoiceFormField($field->getNode(0))); 1297 | $field->getNode(0)->setAttribute('checked', 'checked'); 1298 | if (!$formField instanceof ChoiceFormField) { 1299 | throw new TestRuntimeException("Form field {$name} is not a checkable"); 1300 | } 1301 | 1302 | return $formField; 1303 | } 1304 | 1305 | public function attachFile($field, string $filename): void 1306 | { 1307 | $form = $this->getFormFor($field = $this->getFieldByLabelOrCss($field)); 1308 | $filePath = codecept_data_dir() . $filename; 1309 | if (!file_exists($filePath)) { 1310 | throw new InvalidArgumentException("File does not exist: {$filePath}"); 1311 | } 1312 | 1313 | if (!is_readable($filePath)) { 1314 | throw new InvalidArgumentException("File is not readable: {$filePath}"); 1315 | } 1316 | 1317 | $name = $field->attr('name'); 1318 | $formField = $this->matchFormField($name, $form, new FileFormField($field->getNode(0))); 1319 | if (is_array($formField)) { 1320 | $this->fail("Field {$name} is ignored on upload, field {$name} is treated as array."); 1321 | } 1322 | 1323 | $formField->upload($filePath); 1324 | } 1325 | 1326 | /** 1327 | * Sends an ajax GET request with the passed parameters. 1328 | * See `sendAjaxPostRequest()` 1329 | */ 1330 | public function sendAjaxGetRequest(string $uri, array $params = []): void 1331 | { 1332 | $this->sendAjaxRequest('GET', $uri, $params); 1333 | } 1334 | 1335 | /** 1336 | * Sends an ajax POST request with the passed parameters. 1337 | * The appropriate HTTP header is added automatically: 1338 | * `X-Requested-With: XMLHttpRequest` 1339 | * Example: 1340 | * ``` php 1341 | * sendAjaxPostRequest('/add-task', ['task' => 'lorem ipsum']); 1343 | * ``` 1344 | * Some frameworks (e.g. Symfony) create field names in the form of an "array": 1345 | * `` 1346 | * In this case you need to pass the fields like this: 1347 | * ``` php 1348 | * sendAjaxPostRequest('/add-task', ['form' => [ 1350 | * 'task' => 'lorem ipsum', 1351 | * 'category' => 'miscellaneous', 1352 | * ]]); 1353 | * ``` 1354 | */ 1355 | public function sendAjaxPostRequest(string $uri, array $params = []): void 1356 | { 1357 | $this->sendAjaxRequest('POST', $uri, $params); 1358 | } 1359 | 1360 | /** 1361 | * Sends an ajax request, using the passed HTTP method. 1362 | * See `sendAjaxPostRequest()` 1363 | * Example: 1364 | * ``` php 1365 | * sendAjaxRequest('PUT', '/posts/7', ['title' => 'new title']); 1367 | * ``` 1368 | */ 1369 | public function sendAjaxRequest(string $method, string $uri, array $params = []): void 1370 | { 1371 | $this->clientRequest($method, $uri, $params, [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], null, false); 1372 | } 1373 | 1374 | /** 1375 | * @param mixed $url 1376 | */ 1377 | protected function debugResponse($url): void 1378 | { 1379 | $this->debugSection('Page', $url); 1380 | $this->debugSection('Response', $this->getResponseStatusCode()); 1381 | $this->debugSection('Request Cookies', $this->getRunningClient()->getInternalRequest()->getCookies()); 1382 | $this->debugSection('Response Headers', $this->getRunningClient()->getInternalResponse()->getHeaders()); 1383 | } 1384 | 1385 | public function makeHtmlSnapshot(?string $name = null): void 1386 | { 1387 | if (empty($name)) { 1388 | $name = uniqid(date("Y-m-d_H-i-s_"), true); 1389 | } 1390 | 1391 | $debugDir = codecept_output_dir() . 'debug'; 1392 | if (!is_dir($debugDir)) { 1393 | mkdir($debugDir); 1394 | } 1395 | 1396 | $fileName = $debugDir . DIRECTORY_SEPARATOR . $name . '.html'; 1397 | 1398 | $this->_savePageSource($fileName); 1399 | $this->debugSection('Snapshot Saved', "file://{$fileName}"); 1400 | } 1401 | 1402 | public function _getResponseStatusCode() 1403 | { 1404 | return $this->getResponseStatusCode(); 1405 | } 1406 | 1407 | protected function getResponseStatusCode() 1408 | { 1409 | // depending on Symfony version 1410 | $response = $this->getRunningClient()->getInternalResponse(); 1411 | if (method_exists($response, 'getStatusCode')) { 1412 | return $response->getStatusCode(); 1413 | } 1414 | 1415 | if (method_exists($response, 'getStatus')) { 1416 | return $response->getStatus(); 1417 | } 1418 | 1419 | return "N/A"; 1420 | } 1421 | 1422 | /** 1423 | * @param string|string[] $selector 1424 | */ 1425 | protected function match(string|array $selector): SymfonyCrawler 1426 | { 1427 | if (is_array($selector)) { 1428 | return $this->strictMatch($selector); 1429 | } 1430 | 1431 | if (Locator::isCSS($selector)) { 1432 | return $this->getCrawler()->filter($selector); 1433 | } 1434 | 1435 | if (Locator::isXPath($selector)) { 1436 | return $this->getCrawler()->filterXPath($selector); 1437 | } 1438 | 1439 | throw new MalformedLocatorException($selector, 'XPath or CSS'); 1440 | } 1441 | 1442 | /** 1443 | * @param string[] $by 1444 | * @throws TestRuntimeException 1445 | */ 1446 | protected function strictMatch(array $by): SymfonyCrawler 1447 | { 1448 | $type = key($by); 1449 | $locator = $by[$type]; 1450 | return match ($type) { 1451 | 'id' => $this->filterByCSS(sprintf('#%s', $locator)), 1452 | 'name' => $this->filterByXPath(sprintf('.//*[@name=%s]', SymfonyCrawler::xpathLiteral($locator))), 1453 | 'css' => $this->filterByCSS($locator), 1454 | 'xpath' => $this->filterByXPath($locator), 1455 | 'link' => $this->filterByXPath(sprintf('.//a[.=%s or contains(./@title, %s)]', SymfonyCrawler::xpathLiteral($locator), SymfonyCrawler::xpathLiteral($locator))), 1456 | 'class' => $this->filterByCSS(".{$locator}"), 1457 | default => throw new TestRuntimeException( 1458 | "Locator type '{$by}' is not defined. Use either: xpath, css, id, link, class, name" 1459 | ), 1460 | }; 1461 | } 1462 | 1463 | protected function filterByAttributes(Crawler $nodes, array $attributes) 1464 | { 1465 | foreach ($attributes as $attr => $val) { 1466 | $nodes = $nodes->reduce( 1467 | static fn(Crawler $node): bool => $node->attr($attr) === $val 1468 | ); 1469 | } 1470 | 1471 | return $nodes; 1472 | } 1473 | 1474 | public function grabTextFrom($cssOrXPathOrRegex): mixed 1475 | { 1476 | if (is_string($cssOrXPathOrRegex) && @preg_match($cssOrXPathOrRegex, $this->client->getInternalResponse()->getContent(), $matches)) { 1477 | return $matches[1]; 1478 | } 1479 | 1480 | $nodes = $this->match($cssOrXPathOrRegex); 1481 | if ($nodes->count() !== 0) { 1482 | return $nodes->first()->text(); 1483 | } 1484 | 1485 | throw new ElementNotFound($cssOrXPathOrRegex, 'Element that matches CSS or XPath or Regex'); 1486 | } 1487 | 1488 | public function grabAttributeFrom($cssOrXpath, string $attribute): mixed 1489 | { 1490 | $nodes = $this->match($cssOrXpath); 1491 | if ($nodes->count() === 0) { 1492 | throw new ElementNotFound($cssOrXpath, 'Element that matches CSS or XPath'); 1493 | } 1494 | 1495 | return $nodes->first()->attr($attribute); 1496 | } 1497 | 1498 | public function grabMultiple($cssOrXpath, ?string $attribute = null): array 1499 | { 1500 | $result = []; 1501 | $nodes = $this->match($cssOrXpath); 1502 | 1503 | foreach ($nodes as $node) { 1504 | $result[] = $attribute !== null ? $node->getAttribute($attribute) : $node->textContent; 1505 | } 1506 | 1507 | return $result; 1508 | } 1509 | 1510 | public function grabValueFrom($field): mixed 1511 | { 1512 | $nodes = $this->match($field); 1513 | if ($nodes->count() === 0) { 1514 | throw new ElementNotFound($field, 'Field'); 1515 | } 1516 | 1517 | if ($nodes->filter('textarea')->count() !== 0) { 1518 | return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue(); 1519 | } 1520 | 1521 | $input = $nodes->filter('input'); 1522 | if ($input->count() !== 0) { 1523 | return $this->getInputValue($input); 1524 | } 1525 | 1526 | if ($nodes->filter('select')->count() !== 0) { 1527 | $field = new ChoiceFormField($nodes->filter('select')->getNode(0)); 1528 | $options = $nodes->filter('option[selected]'); 1529 | $values = []; 1530 | 1531 | foreach ($options as $option) { 1532 | $values[] = $option->getAttribute('value'); 1533 | } 1534 | 1535 | if (!$field->isMultiple()) { 1536 | return reset($values); 1537 | } 1538 | 1539 | return $values; 1540 | } 1541 | 1542 | $this->fail("Element {$nodes} is not a form field or does not contain a form field"); 1543 | } 1544 | 1545 | public function setCookie($name, $val, $params = []) 1546 | { 1547 | $cookies = $this->client->getCookieJar(); 1548 | $params = array_merge($this->defaultCookieParameters, $params); 1549 | 1550 | $expires = $params['expiry'] ?? null; // WebDriver compatibility 1551 | $expires = isset($params['expires']) && !$expires ? $params['expires'] : null; 1552 | 1553 | $path = $params['path'] ?? null; 1554 | $domain = $params['domain'] ?? ''; 1555 | $secure = $params['secure'] ?? false; 1556 | $httpOnly = $params['httpOnly'] ?? true; 1557 | $encodedValue = $params['encodedValue'] ?? false; 1558 | 1559 | 1560 | 1561 | $cookies->set(new Cookie($name, $val, $expires, $path, $domain, $secure, $httpOnly, $encodedValue)); 1562 | $this->debugCookieJar(); 1563 | } 1564 | 1565 | public function grabCookie(string $cookie, array $params = []): mixed 1566 | { 1567 | $params = array_merge($this->defaultCookieParameters, $params); 1568 | $this->debugCookieJar(); 1569 | $cookies = $this->getRunningClient()->getCookieJar()->get($cookie, $params['path'], $params['domain']); 1570 | if ($cookies === null) { 1571 | return null; 1572 | } 1573 | 1574 | return $cookies->getValue(); 1575 | } 1576 | 1577 | /** 1578 | * Grabs current page source code. 1579 | * 1580 | * @throws \Codeception\Exception\ModuleException if no page was opened. 1581 | * @return string Current page source code. 1582 | */ 1583 | public function grabPageSource(): string 1584 | { 1585 | return $this->_getResponseContent(); 1586 | } 1587 | 1588 | public function seeCookie($cookie, $params = []) 1589 | { 1590 | $params = array_merge($this->defaultCookieParameters, $params); 1591 | $this->debugCookieJar(); 1592 | $this->assertNotNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain'])); 1593 | } 1594 | 1595 | public function dontSeeCookie($cookie, $params = []) 1596 | { 1597 | $params = array_merge($this->defaultCookieParameters, $params); 1598 | $this->debugCookieJar(); 1599 | $this->assertNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain'])); 1600 | } 1601 | 1602 | public function resetCookie($cookie, $params = []) 1603 | { 1604 | $params = array_merge($this->defaultCookieParameters, $params); 1605 | $this->client->getCookieJar()->expire($cookie, $params['path'], $params['domain']); 1606 | $this->debugCookieJar(); 1607 | } 1608 | 1609 | private function stringifySelector($selector): string 1610 | { 1611 | if (is_array($selector)) { 1612 | return trim(json_encode($selector, JSON_THROW_ON_ERROR), '{}'); 1613 | } 1614 | 1615 | return $selector; 1616 | } 1617 | 1618 | public function seeElement($selector, array $attributes = []): void 1619 | { 1620 | $nodes = $this->match($selector); 1621 | $selector = $this->stringifySelector($selector); 1622 | if (!empty($attributes)) { 1623 | $nodes = $this->filterByAttributes($nodes, $attributes); 1624 | $selector .= "' with attribute(s) '" . trim(json_encode($attributes, JSON_THROW_ON_ERROR), '{}'); 1625 | } 1626 | 1627 | $this->assertDomContains($nodes, $selector); 1628 | } 1629 | 1630 | public function dontSeeElement($selector, array $attributes = []): void 1631 | { 1632 | $nodes = $this->match($selector); 1633 | $selector = $this->stringifySelector($selector); 1634 | if (!empty($attributes)) { 1635 | $nodes = $this->filterByAttributes($nodes, $attributes); 1636 | $selector .= "' with attribute(s) '" . trim(json_encode($attributes, JSON_THROW_ON_ERROR), '{}'); 1637 | } 1638 | 1639 | $this->assertDomNotContains($nodes, $selector); 1640 | } 1641 | 1642 | public function seeNumberOfElements($selector, $expected): void 1643 | { 1644 | $counted = count($this->match($selector)); 1645 | if (is_array($expected)) { 1646 | [$floor, $ceil] = $expected; 1647 | $this->assertTrue( 1648 | $floor <= $counted && $ceil >= $counted, 1649 | 'Number of elements counted differs from expected range' 1650 | ); 1651 | } else { 1652 | $this->assertSame( 1653 | $expected, 1654 | $counted, 1655 | 'Number of elements counted differs from expected number' 1656 | ); 1657 | } 1658 | } 1659 | 1660 | public function seeOptionIsSelected($selector, $optionText) 1661 | { 1662 | $selected = $this->matchSelectedOption($selector); 1663 | $this->assertDomContains($selected, 'selected option'); 1664 | //If element is radio then we need to check value 1665 | $value = $selected->getNode(0)->tagName === 'option' 1666 | ? $selected->text() 1667 | : $selected->getNode(0)->getAttribute('value'); 1668 | $this->assertSame($optionText, $value); 1669 | } 1670 | 1671 | public function dontSeeOptionIsSelected($selector, $optionText) 1672 | { 1673 | $selected = $this->matchSelectedOption($selector); 1674 | if ($selected->count() === 0) { 1675 | $this->assertSame(0, $selected->count()); 1676 | return; 1677 | } 1678 | 1679 | //If element is radio then we need to check value 1680 | $value = $selected->getNode(0)->tagName === 'option' 1681 | ? $selected->text() 1682 | : $selected->getNode(0)->getAttribute('value'); 1683 | $this->assertNotSame($optionText, $value); 1684 | } 1685 | 1686 | protected function matchSelectedOption($select): SymfonyCrawler 1687 | { 1688 | $nodes = $this->getFieldsByLabelOrCss($select); 1689 | $selectedOptions = $nodes->filter('option[selected],input:checked'); 1690 | if ($selectedOptions->count() === 0) { 1691 | $selectedOptions = $nodes->filter('option,input')->first(); 1692 | } 1693 | 1694 | return $selectedOptions; 1695 | } 1696 | 1697 | /** 1698 | * Asserts that current page has 404 response status code. 1699 | */ 1700 | public function seePageNotFound(): void 1701 | { 1702 | $this->seeResponseCodeIs(404); 1703 | } 1704 | 1705 | /** 1706 | * Checks that response code is equal to value provided. 1707 | * 1708 | * ```php 1709 | * seeResponseCodeIs(200); 1711 | * 1712 | * // recommended \Codeception\Util\HttpCode 1713 | * $I->seeResponseCodeIs(\Codeception\Util\HttpCode::OK); 1714 | * ``` 1715 | */ 1716 | public function seeResponseCodeIs(int $code): void 1717 | { 1718 | $failureMessage = sprintf( 1719 | 'Expected HTTP Status Code: %s. Actual Status Code: %s', 1720 | HttpCode::getDescription($code), 1721 | HttpCode::getDescription($this->getResponseStatusCode()) 1722 | ); 1723 | $this->assertSame($code, $this->getResponseStatusCode(), $failureMessage); 1724 | } 1725 | 1726 | /** 1727 | * Checks that response code is between a certain range. Between actually means [from <= CODE <= to] 1728 | */ 1729 | public function seeResponseCodeIsBetween(int $from, int $to): void 1730 | { 1731 | $failureMessage = sprintf( 1732 | 'Expected HTTP Status Code between %s and %s. Actual Status Code: %s', 1733 | HttpCode::getDescription($from), 1734 | HttpCode::getDescription($to), 1735 | HttpCode::getDescription($this->getResponseStatusCode()) 1736 | ); 1737 | $this->assertGreaterThanOrEqual($from, $this->getResponseStatusCode(), $failureMessage); 1738 | $this->assertLessThanOrEqual($to, $this->getResponseStatusCode(), $failureMessage); 1739 | } 1740 | 1741 | /** 1742 | * Checks that response code is equal to value provided. 1743 | * 1744 | * ```php 1745 | * dontSeeResponseCodeIs(200); 1747 | * 1748 | * // recommended \Codeception\Util\HttpCode 1749 | * $I->dontSeeResponseCodeIs(\Codeception\Util\HttpCode::OK); 1750 | * ``` 1751 | */ 1752 | public function dontSeeResponseCodeIs(int $code): void 1753 | { 1754 | $failureMessage = sprintf( 1755 | 'Expected HTTP status code other than %s', 1756 | HttpCode::getDescription($code) 1757 | ); 1758 | $this->assertNotSame($code, $this->getResponseStatusCode(), $failureMessage); 1759 | } 1760 | 1761 | /** 1762 | * Checks that the response code 2xx 1763 | */ 1764 | public function seeResponseCodeIsSuccessful(): void 1765 | { 1766 | $this->seeResponseCodeIsBetween(200, 299); 1767 | } 1768 | 1769 | /** 1770 | * Checks that the response code 3xx 1771 | */ 1772 | public function seeResponseCodeIsRedirection(): void 1773 | { 1774 | $this->seeResponseCodeIsBetween(300, 399); 1775 | } 1776 | 1777 | /** 1778 | * Checks that the response code is 4xx 1779 | */ 1780 | public function seeResponseCodeIsClientError(): void 1781 | { 1782 | $this->seeResponseCodeIsBetween(400, 499); 1783 | } 1784 | 1785 | /** 1786 | * Checks that the response code is 5xx 1787 | */ 1788 | public function seeResponseCodeIsServerError(): void 1789 | { 1790 | $this->seeResponseCodeIsBetween(500, 599); 1791 | } 1792 | 1793 | public function seeInTitle($title) 1794 | { 1795 | $nodes = $this->getCrawler()->filter('title'); 1796 | if ($nodes->count() === 0) { 1797 | throw new ElementNotFound("", "Tag"); 1798 | } 1799 | 1800 | $this->assertStringContainsString($title, $nodes->first()->text(), "page title contains {$title}"); 1801 | } 1802 | 1803 | public function dontSeeInTitle($title) 1804 | { 1805 | $nodes = $this->getCrawler()->filter('title'); 1806 | if ($nodes->count() === 0) { 1807 | $this->assertTrue(true); 1808 | return; 1809 | } 1810 | 1811 | $this->assertStringNotContainsString($title, $nodes->first()->text(), "page title contains {$title}"); 1812 | } 1813 | 1814 | protected function assertDomContains($nodes, string $message, string $text = ''): void 1815 | { 1816 | $constraint = new CrawlerConstraint($text, $this->_getCurrentUri()); 1817 | $this->assertThat($nodes, $constraint, $message); 1818 | } 1819 | 1820 | protected function assertDomNotContains($nodes, string $message, string $text = ''): void 1821 | { 1822 | $constraint = new CrawlerNotConstraint($text, $this->_getCurrentUri()); 1823 | $this->assertThat($nodes, $constraint, $message); 1824 | } 1825 | 1826 | protected function assertPageContains(string $needle, string $message = ''): void 1827 | { 1828 | $constraint = new PageConstraint($needle, $this->_getCurrentUri()); 1829 | $this->assertThat( 1830 | $this->getNormalizedResponseContent(), 1831 | $constraint, 1832 | $message 1833 | ); 1834 | } 1835 | 1836 | protected function assertPageNotContains(string $needle, string $message = ''): void 1837 | { 1838 | $constraint = new PageConstraint($needle, $this->_getCurrentUri()); 1839 | $this->assertThatItsNot( 1840 | $this->getNormalizedResponseContent(), 1841 | $constraint, 1842 | $message 1843 | ); 1844 | } 1845 | 1846 | protected function assertPageSourceContains(string $needle, string $message = ''): void 1847 | { 1848 | $constraint = new PageConstraint($needle, $this->_getCurrentUri()); 1849 | $this->assertThat( 1850 | $this->_getResponseContent(), 1851 | $constraint, 1852 | $message 1853 | ); 1854 | } 1855 | 1856 | protected function assertPageSourceNotContains(string $needle, string $message = ''): void 1857 | { 1858 | $constraint = new PageConstraint($needle, $this->_getCurrentUri()); 1859 | $this->assertThatItsNot( 1860 | $this->_getResponseContent(), 1861 | $constraint, 1862 | $message 1863 | ); 1864 | } 1865 | 1866 | /** 1867 | * @param array|object $form 1868 | */ 1869 | protected function matchFormField(string $name, $form, FormField $dynamicField): FormField|array 1870 | { 1871 | if (!str_ends_with($name, '[]')) { 1872 | return $form[$name]; 1873 | } 1874 | 1875 | $name = substr($name, 0, -2); 1876 | /** @var FormField $item */ 1877 | foreach ($form[$name] as $item) { 1878 | if ($item == $dynamicField) { 1879 | return $item; 1880 | } 1881 | } 1882 | 1883 | throw new TestRuntimeException("None of form fields by {$name}[] were not matched"); 1884 | } 1885 | 1886 | protected function filterByCSS(string $locator): SymfonyCrawler 1887 | { 1888 | if (!Locator::isCSS($locator)) { 1889 | throw new MalformedLocatorException($locator, 'css'); 1890 | } 1891 | 1892 | return $this->getCrawler()->filter($locator); 1893 | } 1894 | 1895 | protected function filterByXPath(string $locator): SymfonyCrawler 1896 | { 1897 | if (!Locator::isXPath($locator)) { 1898 | throw new MalformedLocatorException($locator, 'xpath'); 1899 | } 1900 | 1901 | return $this->getCrawler()->filterXPath($locator); 1902 | } 1903 | 1904 | protected function getFormPhpValues(array $requestParams): array 1905 | { 1906 | foreach ($requestParams as $name => $value) { 1907 | $qs = http_build_query([$name => $value]); 1908 | if (!empty($qs)) { 1909 | // If the field's name is of the form of "array[key]", 1910 | // we'll remove it from the request parameters 1911 | // and set the "array" key instead which will contain the actual array. 1912 | if (strpos($name, '[') && strpos($name, ']') > strpos($name, '[')) { 1913 | unset($requestParams[$name]); 1914 | } 1915 | 1916 | parse_str($qs, $expandedValue); 1917 | $varName = substr($name, 0, strlen((string)key($expandedValue))); 1918 | $requestParams = array_replace_recursive($requestParams, [$varName => current($expandedValue)]); 1919 | } 1920 | } 1921 | 1922 | return $requestParams; 1923 | } 1924 | 1925 | protected function redirectIfNecessary(SymfonyCrawler $result, int $maxRedirects, int $redirectCount): SymfonyCrawler 1926 | { 1927 | $locationHeader = $this->client->getInternalResponse()->getHeader('Location'); 1928 | $statusCode = $this->getResponseStatusCode(); 1929 | if ($locationHeader && $statusCode >= 300 && $statusCode < 400) { 1930 | if ($redirectCount === $maxRedirects) { 1931 | throw new LogicException(sprintf( 1932 | 'The maximum number (%d) of redirections was reached.', 1933 | $maxRedirects 1934 | )); 1935 | } 1936 | 1937 | $this->debugSection('Redirecting to', $locationHeader); 1938 | 1939 | $result = $this->client->followRedirect(); 1940 | $this->debugResponse($locationHeader); 1941 | return $this->redirectIfNecessary($result, $maxRedirects, $redirectCount + 1); 1942 | } 1943 | 1944 | $this->client->followRedirects(true); 1945 | return $result; 1946 | } 1947 | 1948 | /** 1949 | * Switch to iframe or frame on the page. 1950 | * 1951 | * Example: 1952 | * ``` html 1953 | * <iframe name="another_frame" src="http://example.com"> 1954 | * ``` 1955 | * 1956 | * ``` php 1957 | * <?php 1958 | * # switch to iframe 1959 | * $I->switchToIframe("another_frame"); 1960 | * ``` 1961 | */ 1962 | public function switchToIframe(string $name): void 1963 | { 1964 | $iframe = $this->match("iframe[name={$name}]")->first(); 1965 | if (count($iframe) === 0) { 1966 | $iframe = $this->match("frame[name={$name}]")->first(); 1967 | } 1968 | 1969 | if (count($iframe) === 0) { 1970 | throw new ElementNotFound("name={$name}", 'Iframe'); 1971 | } 1972 | 1973 | $uri = $iframe->getNode(0)->getAttribute('src'); 1974 | $this->amOnPage($uri); 1975 | } 1976 | 1977 | /** 1978 | * Moves back in history. 1979 | * 1980 | * @param int $numberOfSteps (default value 1) 1981 | */ 1982 | public function moveBack(int $numberOfSteps = 1): void 1983 | { 1984 | $request = null; 1985 | if (!is_int($numberOfSteps) || $numberOfSteps < 1) { 1986 | throw new InvalidArgumentException('numberOfSteps must be positive integer'); 1987 | } 1988 | 1989 | try { 1990 | $history = $this->getRunningClient()->getHistory(); 1991 | for ($i = $numberOfSteps; $i > 0; --$i) { 1992 | $request = $history->back(); 1993 | } 1994 | } catch (LogicException $exception) { 1995 | throw new InvalidArgumentException( 1996 | sprintf( 1997 | 'numberOfSteps is set to %d, but there are only %d previous steps in the history', 1998 | $numberOfSteps, 1999 | $numberOfSteps - $i 2000 | ), $exception->getCode(), $exception); 2001 | } 2002 | 2003 | $this->_loadPage( 2004 | $request->getMethod(), 2005 | $request->getUri(), 2006 | $request->getParameters(), 2007 | $request->getFiles(), 2008 | $request->getServer(), 2009 | $request->getContent() 2010 | ); 2011 | } 2012 | 2013 | protected function debugCookieJar(): void 2014 | { 2015 | $cookies = $this->client->getCookieJar()->all(); 2016 | $cookieStrings = array_map('strval', $cookies); 2017 | $this->debugSection('Cookie Jar', $cookieStrings); 2018 | } 2019 | 2020 | protected function setCookiesFromOptions() 2021 | { 2022 | if (isset($this->config['cookies']) && is_array($this->config['cookies']) && !empty($this->config['cookies'])) { 2023 | $domain = parse_url($this->config['url'], PHP_URL_HOST); 2024 | $cookieJar = $this->client->getCookieJar(); 2025 | foreach ($this->config['cookies'] as &$cookie) { 2026 | if (!is_array($cookie) || !array_key_exists('Name', $cookie) || !array_key_exists('Value', $cookie)) { 2027 | throw new InvalidArgumentException('Cookies must have at least Name and Value attributes'); 2028 | } 2029 | 2030 | if (!isset($cookie['Domain'])) { 2031 | $cookie['Domain'] = $domain; 2032 | } 2033 | 2034 | if (!isset($cookie['Expires'])) { 2035 | $cookie['Expires'] = null; 2036 | } 2037 | 2038 | if (!isset($cookie['Path'])) { 2039 | $cookie['Path'] = '/'; 2040 | } 2041 | 2042 | if (!isset($cookie['Secure'])) { 2043 | $cookie['Secure'] = false; 2044 | } 2045 | 2046 | if (!isset($cookie['HttpOnly'])) { 2047 | $cookie['HttpOnly'] = false; 2048 | } 2049 | 2050 | $cookieJar->set(new Cookie( 2051 | $cookie['Name'], 2052 | $cookie['Value'], 2053 | $cookie['Expires'], 2054 | $cookie['Path'], 2055 | $cookie['Domain'], 2056 | $cookie['Secure'], 2057 | $cookie['HttpOnly'] 2058 | )); 2059 | } 2060 | } 2061 | } 2062 | 2063 | protected function getNormalizedResponseContent(): string 2064 | { 2065 | $content = $this->_getResponseContent(); 2066 | // Since strip_tags has problems with JS code that contains 2067 | // an <= operator the script tags have to be removed manually first. 2068 | $content = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content); 2069 | 2070 | $content = strip_tags($content); 2071 | $content = html_entity_decode($content, ENT_QUOTES); 2072 | $content = str_replace("\n", ' ', $content); 2073 | 2074 | return preg_replace('#\s{2,}#', ' ', $content); 2075 | } 2076 | 2077 | /** 2078 | * Sets SERVER parameters valid for all next requests. 2079 | * this will remove old ones. 2080 | * 2081 | * ```php 2082 | * $I->setServerParameters([]); 2083 | * ``` 2084 | */ 2085 | public function setServerParameters(array $params): void 2086 | { 2087 | $this->client->setServerParameters($params); 2088 | } 2089 | 2090 | /** 2091 | * Sets SERVER parameter valid for all next requests. 2092 | * 2093 | * ```php 2094 | * $I->haveServerParameter('name', 'value'); 2095 | * ``` 2096 | */ 2097 | public function haveServerParameter(string $name, string $value): void 2098 | { 2099 | $this->client->setServerParameter($name, $value); 2100 | } 2101 | 2102 | /** 2103 | * Prevents automatic redirects to be followed by the client. 2104 | * 2105 | * ```php 2106 | * <?php 2107 | * $I->stopFollowingRedirects(); 2108 | * ``` 2109 | */ 2110 | public function stopFollowingRedirects(): void 2111 | { 2112 | $this->client->followRedirects(false); 2113 | } 2114 | 2115 | /** 2116 | * Enables automatic redirects to be followed by the client. 2117 | * 2118 | * ```php 2119 | * <?php 2120 | * $I->startFollowingRedirects(); 2121 | * ``` 2122 | */ 2123 | public function startFollowingRedirects(): void 2124 | { 2125 | $this->client->followRedirects(true); 2126 | } 2127 | 2128 | /** 2129 | * Follow pending redirect if there is one. 2130 | * 2131 | * ```php 2132 | * <?php 2133 | * $I->followRedirect(); 2134 | * ``` 2135 | */ 2136 | public function followRedirect(): void 2137 | { 2138 | $this->client->followRedirect(); 2139 | } 2140 | 2141 | /** 2142 | * Sets the maximum number of redirects that the Client can follow. 2143 | * 2144 | * ```php 2145 | * <?php 2146 | * $I->setMaxRedirects(2); 2147 | * ``` 2148 | */ 2149 | public function setMaxRedirects(int $maxRedirects): void 2150 | { 2151 | $this->client->setMaxRedirects($maxRedirects); 2152 | } 2153 | } 2154 | -------------------------------------------------------------------------------- /src/Codeception/Util/HttpCode.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Util; 6 | 7 | /** 8 | * Class containing constants of HTTP Status Codes 9 | * and method to print HTTP code with its description. 10 | * 11 | * Usage: 12 | * 13 | * ```php 14 | * <?php 15 | * use \Codeception\Util\HttpCode; 16 | * 17 | * // using REST, PhpBrowser, or any Framework module 18 | * $I->seeResponseCodeIs(HttpCode::OK); 19 | * $I->dontSeeResponseCodeIs(HttpCode::NOT_FOUND); 20 | * ``` 21 | */ 22 | class HttpCode 23 | { 24 | // const CONTINUE = 100; 25 | /** 26 | * @var int 27 | */ 28 | public const SWITCHING_PROTOCOLS = 101; 29 | /** 30 | * @var int 31 | */ 32 | public const PROCESSING = 102; // RFC2518 33 | /** 34 | * @var int 35 | */ 36 | public const EARLY_HINTS = 103; // RFC8297 37 | /** 38 | * @var int 39 | */ 40 | public const OK = 200; 41 | /** 42 | * @var int 43 | */ 44 | public const CREATED = 201; 45 | /** 46 | * @var int 47 | */ 48 | public const ACCEPTED = 202; 49 | /** 50 | * @var int 51 | */ 52 | public const NON_AUTHORITATIVE_INFORMATION = 203; 53 | /** 54 | * @var int 55 | */ 56 | public const NO_CONTENT = 204; 57 | /** 58 | * @var int 59 | */ 60 | public const RESET_CONTENT = 205; 61 | /** 62 | * @var int 63 | */ 64 | public const PARTIAL_CONTENT = 206; 65 | /** 66 | * @var int 67 | */ 68 | public const MULTI_STATUS = 207; // RFC4918 69 | /** 70 | * @var int 71 | */ 72 | public const ALREADY_REPORTED = 208; // RFC5842 73 | /** 74 | * @var int 75 | */ 76 | public const IM_USED = 226; // RFC3229 77 | /** 78 | * @var int 79 | */ 80 | public const MULTIPLE_CHOICES = 300; 81 | /** 82 | * @var int 83 | */ 84 | public const MOVED_PERMANENTLY = 301; 85 | /** 86 | * @var int 87 | */ 88 | public const FOUND = 302; 89 | /** 90 | * @var int 91 | */ 92 | public const SEE_OTHER = 303; 93 | /** 94 | * @var int 95 | */ 96 | public const NOT_MODIFIED = 304; 97 | /** 98 | * @var int 99 | */ 100 | public const USE_PROXY = 305; 101 | /** 102 | * @var int 103 | */ 104 | public const RESERVED = 306; 105 | /** 106 | * @var int 107 | */ 108 | public const TEMPORARY_REDIRECT = 307; 109 | /** 110 | * @var int 111 | */ 112 | public const PERMANENTLY_REDIRECT = 308; // RFC7238 113 | /** 114 | * @var int 115 | */ 116 | public const BAD_REQUEST = 400; 117 | /** 118 | * @var int 119 | */ 120 | public const UNAUTHORIZED = 401; 121 | /** 122 | * @var int 123 | */ 124 | public const PAYMENT_REQUIRED = 402; 125 | /** 126 | * @var int 127 | */ 128 | public const FORBIDDEN = 403; 129 | /** 130 | * @var int 131 | */ 132 | public const NOT_FOUND = 404; 133 | /** 134 | * @var int 135 | */ 136 | public const METHOD_NOT_ALLOWED = 405; 137 | /** 138 | * @var int 139 | */ 140 | public const NOT_ACCEPTABLE = 406; 141 | /** 142 | * @var int 143 | */ 144 | public const PROXY_AUTHENTICATION_REQUIRED = 407; 145 | /** 146 | * @var int 147 | */ 148 | public const REQUEST_TIMEOUT = 408; 149 | /** 150 | * @var int 151 | */ 152 | public const CONFLICT = 409; 153 | /** 154 | * @var int 155 | */ 156 | public const GONE = 410; 157 | /** 158 | * @var int 159 | */ 160 | public const LENGTH_REQUIRED = 411; 161 | /** 162 | * @var int 163 | */ 164 | public const PRECONDITION_FAILED = 412; 165 | /** 166 | * @var int 167 | */ 168 | public const REQUEST_ENTITY_TOO_LARGE = 413; 169 | /** 170 | * @var int 171 | */ 172 | public const REQUEST_URI_TOO_LONG = 414; 173 | /** 174 | * @var int 175 | */ 176 | public const UNSUPPORTED_MEDIA_TYPE = 415; 177 | /** 178 | * @var int 179 | */ 180 | public const REQUESTED_RANGE_NOT_SATISFIABLE = 416; 181 | /** 182 | * @var int 183 | */ 184 | public const EXPECTATION_FAILED = 417; 185 | /** 186 | * @var int 187 | */ 188 | public const I_AM_A_TEAPOT = 418; // RFC2324 189 | /** 190 | * @var int 191 | */ 192 | public const MISDIRECTED_REQUEST = 421; // RFC7540 193 | /** 194 | * @var int 195 | */ 196 | public const UNPROCESSABLE_ENTITY = 422; // RFC4918 197 | /** 198 | * @var int 199 | */ 200 | public const LOCKED = 423; // RFC4918 201 | /** 202 | * @var int 203 | */ 204 | public const FAILED_DEPENDENCY = 424; // RFC4918 205 | /** 206 | * @var int 207 | */ 208 | public const RESERVED_FOR_WEBDAV_ADVANCED_COLLECTIONS_EXPIRED_PROPOSAL = 425; // RFC2817 209 | /** 210 | * @var int 211 | */ 212 | public const UPGRADE_REQUIRED = 426; // RFC2817 213 | /** 214 | * @var int 215 | */ 216 | public const PRECONDITION_REQUIRED = 428; // RFC6585 217 | /** 218 | * @var int 219 | */ 220 | public const TOO_MANY_REQUESTS = 429; // RFC6585 221 | /** 222 | * @var int 223 | */ 224 | public const REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585 225 | /** 226 | * @var int 227 | */ 228 | public const UNAVAILABLE_FOR_LEGAL_REASONS = 451; 229 | /** 230 | * @var int 231 | */ 232 | public const INTERNAL_SERVER_ERROR = 500; 233 | /** 234 | * @var int 235 | */ 236 | public const NOT_IMPLEMENTED = 501; 237 | /** 238 | * @var int 239 | */ 240 | public const BAD_GATEWAY = 502; 241 | /** 242 | * @var int 243 | */ 244 | public const SERVICE_UNAVAILABLE = 503; 245 | /** 246 | * @var int 247 | */ 248 | public const GATEWAY_TIMEOUT = 504; 249 | /** 250 | * @var int 251 | */ 252 | public const VERSION_NOT_SUPPORTED = 505; 253 | /** 254 | * @var int 255 | */ 256 | public const VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295 257 | /** 258 | * @var int 259 | */ 260 | public const INSUFFICIENT_STORAGE = 507; // RFC4918 261 | /** 262 | * @var int 263 | */ 264 | public const LOOP_DETECTED = 508; // RFC5842 265 | /** 266 | * @var int 267 | */ 268 | public const NOT_EXTENDED = 510; // RFC2774 269 | /** 270 | * @var int 271 | */ 272 | public const NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585 273 | /** 274 | * @var array<int, string> 275 | */ 276 | private static array $codes = [ 277 | 100 => 'Continue', 278 | 102 => 'Processing', 279 | 103 => 'Early Hints', 280 | 200 => 'OK', 281 | 201 => 'Created', 282 | 202 => 'Accepted', 283 | 203 => 'Non-Authoritative Information', 284 | 204 => 'No Content', 285 | 205 => 'Reset Content', 286 | 206 => 'Partial Content', 287 | 207 => 'Multi-Status', 288 | 208 => 'Already Reported', 289 | 226 => 'IM Used', 290 | 300 => 'Multiple Choices', 291 | 301 => 'Moved Permanently', 292 | 302 => 'Found', 293 | 303 => 'See Other', 294 | 304 => 'Not Modified', 295 | 305 => 'Use Proxy', 296 | 306 => 'Reserved', 297 | 307 => 'Temporary Redirect', 298 | 308 => 'Permanent Redirect', 299 | 400 => 'Bad Request', 300 | 401 => 'Unauthorized', 301 | 402 => 'Payment Required', 302 | 403 => 'Forbidden', 303 | 404 => 'Not Found', 304 | 405 => 'Method Not Allowed', 305 | 406 => 'Not Acceptable', 306 | 407 => 'Proxy Authentication Required', 307 | 408 => 'Request Timeout', 308 | 409 => 'Conflict', 309 | 410 => 'Gone', 310 | 411 => 'Length Required', 311 | 412 => 'Precondition Failed', 312 | 413 => 'Request Entity Too Large', 313 | 414 => 'Request-URI Too Long', 314 | 415 => 'Unsupported Media Type', 315 | 416 => 'Requested Range Not Satisfiable', 316 | 417 => 'Expectation Failed', 317 | 418 => 'Unassigned', 318 | 421 => 'Misdirected Request', 319 | 422 => 'Unprocessable Entity', 320 | 423 => 'Locked', 321 | 424 => 'Failed Dependency', 322 | 425 => 'Too Early', 323 | 426 => 'Upgrade Required', 324 | 428 => 'Precondition Required', 325 | 429 => 'Too Many Requests', 326 | 431 => 'Request Header Fields Too Large', 327 | 451 => 'Unavailable For Legal Reasons', 328 | 500 => 'Internal Server Error', 329 | 501 => 'Not Implemented', 330 | 502 => 'Bad Gateway', 331 | 503 => 'Service Unavailable', 332 | 504 => 'Gateway Timeout', 333 | 505 => 'HTTP Version Not Supported', 334 | 506 => 'Variant Also Negotiates', 335 | 507 => 'Insufficient Storage', 336 | 508 => 'Loop Detected', 337 | 510 => 'Not Extended', 338 | 511 => 'Network Authentication Required' 339 | ]; 340 | 341 | /** 342 | * Returns string with HTTP code and its description 343 | * 344 | * ```php 345 | * <?php 346 | * HttpCode::getDescription(200); // '200 (OK)' 347 | * HttpCode::getDescription(401); // '401 (Unauthorized)' 348 | * ``` 349 | */ 350 | public static function getDescription(int $code): int|string 351 | { 352 | if (isset(self::$codes[$code])) { 353 | return sprintf('%d (%s)', $code, self::$codes[$code]); 354 | } 355 | return $code; 356 | } 357 | } 358 | --------------------------------------------------------------------------------