Files
arkham_calc/vendor/codeception/lib-innerbrowser/src/Codeception/Lib/InnerBrowser.php
T
2025-09-25 23:03:07 +03:00

2154 lines
69 KiB
PHP

<?php
declare(strict_types=1);
namespace Codeception\Lib;
use Codeception\Constraint\Crawler as CrawlerConstraint;
use Codeception\Constraint\CrawlerNot as CrawlerNotConstraint;
use Codeception\Constraint\Page as PageConstraint;
use Codeception\Exception\ElementNotFound;
use Codeception\Exception\ExternalUrlException;
use Codeception\Exception\MalformedLocatorException;
use Codeception\Exception\ModuleException;
use Codeception\Exception\TestRuntimeException;
use Codeception\Lib\Interfaces\ConflictsWithModule;
use Codeception\Lib\Interfaces\ElementLocator;
use Codeception\Lib\Interfaces\PageSourceSaver;
use Codeception\Lib\Interfaces\Web;
use Codeception\Module;
use Codeception\Test\Descriptor;
use Codeception\TestInterface;
use Codeception\Util\HttpCode;
use Codeception\Util\Locator;
use Codeception\Util\ReflectionHelper;
use Codeception\Util\Uri;
use DOMDocument;
use DOMNode;
use InvalidArgumentException;
use LogicException;
use Symfony\Component\BrowserKit\AbstractBrowser;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Crawler as SymfonyCrawler;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\DomCrawler\Field\FileFormField;
use Symfony\Component\DomCrawler\Field\FormField;
use Symfony\Component\DomCrawler\Field\InputFormField;
use Symfony\Component\DomCrawler\Field\TextareaFormField;
use Symfony\Component\DomCrawler\Form as SymfonyForm;
use Symfony\Component\DomCrawler\Link;
class InnerBrowser extends Module implements Web, PageSourceSaver, ElementLocator, ConflictsWithModule
{
protected ?SymfonyCrawler $crawler = null;
/**
* @api
*/
public ?AbstractBrowser $client = null;
/**
* @var SymfonyForm[]
*/
protected array $forms = [];
/**
* @var string[]
*/
public array $headers = [];
/**
* @var array<string, string>|array<string, bool>|array<string, null>
*/
protected array $defaultCookieParameters = ['expires' => null, 'path' => '/', 'domain' => '', 'secure' => false];
/**
* @var string[]|null
*/
protected ?array $internalDomains = null;
private ?string $baseUrl = null;
public function _failed(TestInterface $test, $fail)
{
try {
if (!$this->client || !$this->client->getInternalResponse()) {
return;
}
} catch (BadMethodCallException) {
//Symfony 5 throws exception if request() method threw an exception.
//The "request()" method must be called before "Symfony\Component\BrowserKit\AbstractBrowser::getInternalResponse()"
return;
}
$filename = preg_replace('#\W#', '.', Descriptor::getTestSignatureUnique($test));
$extensions = [
'application/json' => 'json',
'text/xml' => 'xml',
'application/xml' => 'xml',
'text/plain' => 'txt'
];
try {
$internalResponse = $this->client->getInternalResponse();
} catch (BadMethodCallException) {
$internalResponse = false;
}
$responseContentType = $internalResponse ? (string) $internalResponse->getHeader('content-type') : '';
[$responseMimeType] = explode(';', $responseContentType);
$extension = $extensions[$responseMimeType] ?? 'html';
$filename = mb_strcut($filename, 0, 244, 'utf-8') . '.fail.' . $extension;
$this->_savePageSource($report = codecept_output_dir() . $filename);
$test->getMetadata()->addReport('html', $report);
$test->getMetadata()->addReport('response', $report);
}
public function _after(TestInterface $test)
{
$this->client = null;
$this->crawler = null;
$this->forms = [];
$this->headers = [];
}
/**
* @return class-string
*/
public function _conflicts(): string
{
return \Codeception\Lib\Interfaces\Web::class;
}
public function _findElements(mixed $locator): iterable
{
return $this->match($locator);
}
/**
* Send custom request to a backend using method, uri, parameters, etc.
* Use it in Helpers to create special request actions, like accessing API
* Returns a string with response body.
*
* ```php
* <?php
* // in Helper class
* public function createUserByApi($name) {
* $userData = $this->getModule('{{MODULE_NAME}}')->_request('POST', '/api/v1/users', ['name' => $name]);
* $user = json_decode($userData);
* return $user->id;
* }
* ```
* Does not load the response into the module so you can't interact with response page (click, fill forms).
* To load arbitrary page for interaction, use `_loadPage` method.
*
* @throws ExternalUrlException|ModuleException
* @api
* @see `_loadPage`
*/
public function _request(
string $method,
string $uri,
array $parameters = [],
array $files = [],
array $server = [],
?string $content = null
): ?string {
$this->clientRequest($method, $uri, $parameters, $files, $server, $content);
return $this->_getResponseContent();
}
/**
* Returns content of the last response
* Use it in Helpers when you want to retrieve response of request performed by another module.
*
* ```php
* <?php
* // in Helper class
* public function seeResponseContains($text)
* {
* $this->assertStringContainsString($text, $this->getModule('{{MODULE_NAME}}')->_getResponseContent(), "response contains");
* }
* ```
*
* @api
* @throws ModuleException
*/
public function _getResponseContent(): string
{
return $this->getRunningClient()->getInternalResponse()->getContent();
}
protected function clientRequest(
string $method,
string $uri,
array $parameters = [],
array $files = [],
array $server = [],
?string $content = null,
bool $changeHistory = true
): SymfonyCrawler {
$this->debugSection("Request Headers", $this->headers);
foreach ($this->headers as $header => $val) { // moved from REST module
if ($val === null || $val === '') {
continue;
}
$header = str_replace('-', '_', strtoupper($header));
$server["HTTP_{$header}"] = $val;
// Issue #827 - symfony foundation requires 'CONTENT_TYPE' without HTTP_
if ($this instanceof Framework && $header === 'CONTENT_TYPE') {
$server[$header] = $val;
}
}
$server['REQUEST_TIME'] = time();
$server['REQUEST_TIME_FLOAT'] = microtime(true);
if ($this instanceof Framework) {
if (preg_match('#^(//|https?://(?!localhost))#', $uri)) {
$hostname = parse_url($uri, PHP_URL_HOST);
if (!$this->isInternalDomain($hostname)) {
throw new ExternalUrlException($this::class . " can't open external URL: " . $uri);
}
}
if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) && $content === null && !empty($parameters)) {
$content = http_build_query($parameters);
}
}
if (method_exists($this->client, 'isFollowingRedirects')) {
$isFollowingRedirects = $this->client->isFollowingRedirects();
$maxRedirects = $this->client->getMaxRedirects();
} else {
//Symfony 2.7 support
$isFollowingRedirects = ReflectionHelper::readPrivateProperty($this->client, 'followRedirects', 'Symfony\Component\BrowserKit\Client');
$maxRedirects = ReflectionHelper::readPrivateProperty($this->client, 'maxRedirects', 'Symfony\Component\BrowserKit\Client');
}
if (!$isFollowingRedirects) {
$result = $this->client->request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
$this->debugResponse($uri);
return $result;
}
$this->client->followRedirects(false);
$result = $this->client->request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
$this->debugResponse($uri);
return $this->redirectIfNecessary($result, $maxRedirects, 0);
}
protected function isInternalDomain(string $domain): bool
{
if ($this->internalDomains === null) {
$this->internalDomains = $this->getInternalDomains();
}
foreach ($this->internalDomains as $pattern) {
if (preg_match($pattern, $domain)) {
return true;
}
}
return false;
}
/**
* Opens a page with arbitrary request parameters.
* Useful for testing multi-step forms on a specific step.
*
* ```php
* <?php
* // in Helper class
* public function openCheckoutFormStep2($orderId) {
* $this->getModule('{{MODULE_NAME}}')->_loadPage('POST', '/checkout/step2', ['order' => $orderId]);
* }
* ```
*
* @api
*/
public function _loadPage(
string $method,
string $uri,
array $parameters = [],
array $files = [],
array $server = [],
?string $content = null
): void {
$this->crawler = $this->clientRequest($method, $uri, $parameters, $files, $server, $content);
$this->baseUrl = $this->retrieveBaseUrl();
$this->forms = [];
}
/**
* @throws ModuleException
*/
private function getCrawler(): SymfonyCrawler
{
if (!$this->crawler) {
throw new ModuleException($this, 'Crawler is null. Perhaps you forgot to call "amOnPage"?');
}
return $this->crawler;
}
private function getRunningClient(): AbstractBrowser
{
try {
if ($this->client->getInternalRequest() === null) {
throw new ModuleException(
$this,
"Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it"
);
}
} catch (BadMethodCallException) {
//Symfony 5
throw new ModuleException(
$this,
"Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it"
);
}
return $this->client;
}
public function _savePageSource(string $filename): void
{
file_put_contents($filename, $this->_getResponseContent());
}
/**
* Authenticates user for HTTP_AUTH
*/
public function amHttpAuthenticated(string $username, string $password): void
{
$this->client->setServerParameter('PHP_AUTH_USER', $username);
$this->client->setServerParameter('PHP_AUTH_PW', $password);
}
/**
* Sets the HTTP header to the passed value - which is used on
* subsequent HTTP requests through PhpBrowser.
*
* Example:
* ```php
* <?php
* $I->haveHttpHeader('X-Requested-With', 'Codeception');
* $I->amOnPage('test-headers.php');
* ```
*
* To use special chars in Header Key use HTML Character Entities:
* Example:
* Header with underscore - 'Client_Id'
* should be represented as - 'Client&#x0005F;Id' or 'Client&#95;Id'
*
* ```php
* <?php
* $I->haveHttpHeader('Client&#95;Id', 'Codeception');
* ```
*
* @param string $name the name of the request header
* @param string $value the value to set it to for subsequent
* requests
*/
public function haveHttpHeader(string $name, string $value): void
{
$name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name)))));
$this->headers[$name] = $value;
}
/**
* Unsets a HTTP header (that was originally added by [haveHttpHeader()](#haveHttpHeader)),
* so that subsequent requests will not send it anymore.
*
* Example:
* ```php
* <?php
* $I->haveHttpHeader('X-Requested-With', 'Codeception');
* $I->amOnPage('test-headers.php');
* // ...
* $I->unsetHeader('X-Requested-With');
* $I->amOnPage('some-other-page.php');
* ```
*
* @param string $name the name of the header to unset.
*/
public function unsetHttpHeader(string $name): void
{
$name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name)))));
unset($this->headers[$name]);
}
/**
* @deprecated Use [unsetHttpHeader](#unsetHttpHeader) instead
*/
public function deleteHeader(string $name): void
{
$this->unsetHttpHeader($name);
}
public function amOnPage(string $page): void
{
$this->_loadPage('GET', $page);
}
public function click($link, $context = null): void
{
if ($context) {
$this->crawler = $this->match($context);
}
if (is_array($link)) {
$this->clickByLocator($link);
return;
}
$anchor = $this->strictMatch(['link' => $link]);
if (count($anchor) === 0) {
$anchor = $this->getCrawler()->selectLink($link);
}
if (count($anchor) > 0) {
$this->openHrefFromDomNode($anchor->getNode(0));
return;
}
$buttonText = str_replace('"', "'", $link);
$button = $this->crawler->selectButton($buttonText);
if (count($button) && $this->clickButton($button->getNode(0))) {
return;
}
try {
$this->clickByLocator($link);
} catch (MalformedLocatorException) {
throw new ElementNotFound("name={$link}", "'{$link}' is invalid CSS and XPath selector and Link or Button");
}
}
/**
* @param string|string[] $link
*/
protected function clickByLocator(string|array $link): ?bool
{
$nodes = $this->match($link);
if ($nodes->count() === 0) {
throw new ElementNotFound($link, 'Link or Button by name or CSS or XPath');
}
foreach ($nodes as $node) {
$tag = $node->tagName;
$type = $node->getAttribute('type');
if ($tag === 'a') {
$this->openHrefFromDomNode($node);
return true;
}
if (in_array($tag, ['input', 'button']) && in_array($type, ['submit', 'image'])) {
return $this->clickButton($node);
}
}
return null;
}
/**
* Clicks the link or submits the form when the button is clicked
*
* @return bool clicked something
*/
private function clickButton(DOMNode $node): bool
{
/**
* First we check if the button is associated to a form.
* It is associated to a form when it has a nonempty form
*/
$formAttribute = $node->attributes->getNamedItem('form');
if (isset($formAttribute)) {
$form = empty($formAttribute->nodeValue) ? null : $this->filterByCSS('#' . $formAttribute->nodeValue)->getNode(0);
} else {
// Check parents
$currentNode = $node;
$form = null;
while ($currentNode->parentNode !== null) {
$currentNode = $currentNode->parentNode;
if ($currentNode->nodeName === 'form') {
$form = $node;
break;
}
}
}
if (isset($form)) {
$buttonName = $node->getAttribute('name');
$formParams = $buttonName !== '' ? [$buttonName => $node->getAttribute('value')] : [];
$this->proceedSubmitForm(
new SymfonyCrawler($form, $this->getAbsoluteUrlFor($this->_getCurrentUri()), $this->getBaseUrl()),
$formParams
);
return true;
}
// Check if the button is inside an anchor.
$currentNode = $node;
while ($currentNode->parentNode !== null) {
$currentNode = $currentNode->parentNode;
if ($currentNode->nodeName === 'a') {
$this->openHrefFromDomNode($currentNode);
return true;
}
}
throw new TestRuntimeException('Button is not inside a link or a form');
}
private function openHrefFromDomNode(DOMNode $node): void
{
$link = new Link($node, $this->getBaseUrl());
$this->amOnPage(preg_replace('/#.*/', '', $link->getUri()));
}
private function getBaseUrl(): ?string
{
return $this->baseUrl;
}
private function retrieveBaseUrl(): string
{
$baseUrl = '';
$baseHref = $this->crawler->filter('base');
if (count($baseHref) > 0) {
$baseUrl = $baseHref->getNode(0)->getAttribute('href');
}
if ($baseUrl === '') {
$baseUrl = $this->_getCurrentUri();
}
return $this->getAbsoluteUrlFor($baseUrl);
}
public function see(string $text, $selector = null): void
{
if (!$selector) {
$this->assertPageContains($text);
return;
}
$nodes = $this->match($selector);
$this->assertDomContains($nodes, $this->stringifySelector($selector), $text);
}
public function dontSee(string $text, $selector = null): void
{
if (!$selector) {
$this->assertPageNotContains($text);
return;
}
$nodes = $this->match($selector);
$this->assertDomNotContains($nodes, $this->stringifySelector($selector), $text);
}
public function seeInSource(string $raw): void
{
$this->assertPageSourceContains($raw);
}
public function dontSeeInSource(string $raw): void
{
$this->assertPageSourceNotContains($raw);
}
public function seeLink(string $text, ?string $url = null): void
{
$crawler = $this->getCrawler()->selectLink($text);
if ($crawler->count() === 0) {
$this->fail("No links containing text '{$text}' were found in page " . $this->_getCurrentUri());
}
if ($url) {
$crawler = $crawler->filterXPath(sprintf('.//a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', SymfonyCrawler::xpathLiteral($url)));
if ($crawler->count() === 0) {
$this->fail("No links containing text '{$text}' and URL '{$url}' were found in page " . $this->_getCurrentUri());
}
}
$this->assertTrue(true);
}
public function dontSeeLink(string $text, string $url = ''): void
{
$crawler = $this->getCrawler()->selectLink($text);
if (!$url && $crawler->count() > 0) {
$this->fail("Link containing text '{$text}' was found in page " . $this->_getCurrentUri());
}
$crawler = $crawler->filterXPath(
sprintf('.//a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]',
SymfonyCrawler::xpathLiteral((string) $url))
);
if ($crawler->count() > 0) {
$this->fail("Link containing text '{$text}' and URL '{$url}' was found in page " . $this->_getCurrentUri());
}
}
/**
* @throws ModuleException
*/
public function _getCurrentUri(): string
{
return Uri::retrieveUri($this->getRunningClient()->getHistory()->current()->getUri());
}
public function seeInCurrentUrl(string $uri): void
{
$this->assertStringContainsString($uri, $this->_getCurrentUri());
}
public function dontSeeInCurrentUrl(string $uri): void
{
$this->assertStringNotContainsString($uri, $this->_getCurrentUri());
}
public function seeCurrentUrlEquals(string $uri): void
{
$this->assertSame(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/'));
}
public function dontSeeCurrentUrlEquals(string $uri): void
{
$this->assertNotSame(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/'));
}
public function seeCurrentUrlMatches(string $uri): void
{
$this->assertRegExp($uri, $this->_getCurrentUri());
}
public function dontSeeCurrentUrlMatches(string $uri): void
{
$this->assertNotRegExp($uri, $this->_getCurrentUri());
}
public function grabFromCurrentUrl(?string $uri = null): mixed
{
if (!$uri) {
return $this->_getCurrentUri();
}
$matches = [];
$res = preg_match($uri, $this->_getCurrentUri(), $matches);
if (!$res) {
$this->fail("Couldn't match {$uri} in " . $this->_getCurrentUri());
}
if (!isset($matches[1])) {
$this->fail("Nothing to grab. A regex parameter required. Ex: '/user/(\\d+)'");
}
return $matches[1];
}
public function seeCheckboxIsChecked($checkbox): void
{
$checkboxes = $this->getFieldsByLabelOrCss($checkbox);
$this->assertGreaterThan(0, $checkboxes->filter('input[checked]')->count());
}
public function dontSeeCheckboxIsChecked($checkbox): void
{
$checkboxes = $this->getFieldsByLabelOrCss($checkbox);
$this->assertSame(0, $checkboxes->filter('input[checked]')->count());
}
public function seeInField($field, $value): void
{
$nodes = $this->getFieldsByLabelOrCss($field);
$this->assert($this->proceedSeeInField($nodes, $value));
}
public function dontSeeInField($field, $value): void
{
$nodes = $this->getFieldsByLabelOrCss($field);
$this->assertNot($this->proceedSeeInField($nodes, $value));
}
public function seeInFormFields($formSelector, array $params): void
{
$this->proceedSeeInFormFields($formSelector, $params, false);
}
public function dontSeeInFormFields($formSelector, array $params): void
{
$this->proceedSeeInFormFields($formSelector, $params, true);
}
protected function proceedSeeInFormFields($formSelector, array $params, $assertNot)
{
$form = $this->match($formSelector)->first();
if ($form->count() === 0) {
throw new ElementNotFound($formSelector, 'Form');
}
$fields = [];
foreach ($params as $name => $values) {
$this->pushFormField($fields, $form, $name, $values);
}
foreach ($fields as [$field, $values]) {
if (!is_array($values)) {
$values = [$values];
}
foreach ($values as $value) {
$ret = $this->proceedSeeInField($field, $value);
if ($assertNot) {
$this->assertNot($ret);
} else {
$this->assert($ret);
}
}
}
}
/**
* Map an array element passed to seeInFormFields to its corresponding field,
* recursing through array values if the field is not found.
*
* @param array $fields The previously found fields.
* @param SymfonyCrawler $form The form in which to search for fields.
* @param string $name The field's name.
* @param mixed $values
*/
protected function pushFormField(array &$fields, SymfonyCrawler $form, string $name, $values): void
{
$field = $form->filterXPath(sprintf('.//*[@name=%s]', SymfonyCrawler::xpathLiteral($name)));
if ($field->count() !== 0) {
$fields[] = [$field, $values];
} elseif (is_array($values)) {
foreach ($values as $key => $value) {
$this->pushFormField($fields, $form, sprintf('%s[%s]', $name, $key), $value);
}
} else {
throw new ElementNotFound(
sprintf('//*[@name=%s]', SymfonyCrawler::xpathLiteral($name)),
'Form'
);
}
}
protected function proceedSeeInField(Crawler $fields, $value): array
{
$testValues = $this->getValueAndTextFromField($fields);
if (!is_array($testValues)) {
$testValues = [$testValues];
}
if (is_bool($value) && $value && !empty($testValues)) {
$value = reset($testValues);
} elseif (empty($testValues)) {
$testValues = [''];
}
return [
'Contains',
(string)$value,
$testValues,
sprintf(
"Failed asserting that `%s` is in %s's value: %s",
$value,
$fields->getNode(0)->nodeName,
var_export($testValues, true)
)
];
}
/**
* Get the values of a set of fields and also the texts of selected options.
*/
protected function getValueAndTextFromField(Crawler $nodes): array|string
{
if ($nodes->filter('textarea')->count() !== 0) {
return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue();
}
$input = $nodes->filter('input');
if ($input->count() !== 0) {
return $this->getInputValue($input);
}
if ($nodes->filter('select')->count() !== 0) {
$options = $nodes->filter('option[selected]');
$values = [];
foreach ($options as $option) {
$values[] = $option->getAttribute('value');
$values[] = $option->textContent;
$values[] = trim($option->textContent);
}
return $values;
}
$this->fail("Element {$nodes} is not a form field or does not contain a form field");
}
/**
* Get the values of a set of input fields.
*/
protected function getInputValue(SymfonyCrawler $input): array|string
{
$inputType = $input->attr('type');
if ($inputType === 'checkbox' || $inputType === 'radio') {
$values = [];
foreach ($input->filter(':checked') as $checkbox) {
$values[] = $checkbox->getAttribute('value');
}
return $values;
}
return (new InputFormField($input->getNode(0)))->getValue();
}
/**
* Strips out one pair of trailing square brackets from a field's
* name.
*
* @param string $name the field name
* @return string the name after stripping trailing square brackets
*/
protected function getSubmissionFormFieldName(string $name): string
{
if (str_ends_with($name, '[]')) {
return substr($name, 0, -2);
}
return $name;
}
/**
* Replaces boolean values in $params with the corresponding field's
* value for checkbox form fields.
*
* The function loops over all input checkbox fields, checking if a
* corresponding key is set in $params. If it is, and the value is
* boolean or an array containing booleans, the value(s) are
* replaced in the array with the real value of the checkbox, and
* the array is returned.
*
* @param SymfonyCrawler $form the form to find checkbox elements
* @param array $params the parameters to be submitted
* @return array the $params array after replacing bool values
*/
protected function setCheckboxBoolValues(Crawler $form, array $params): array
{
$checkboxes = $form->filter('input[type=checkbox]');
$chFoundByName = [];
foreach ($checkboxes as $checkbox) {
$fieldName = $this->getSubmissionFormFieldName($checkbox->getAttribute('name'));
$pos = $chFoundByName[$fieldName] ?? 0;
$skip = !isset($params[$fieldName])
|| (!is_array($params[$fieldName]) && !is_bool($params[$fieldName]))
|| (is_array($params[$fieldName]) &&
($pos >= count($params[$fieldName]) || !is_bool($params[$fieldName][$pos]))
);
if ($skip) {
continue;
}
$values = $params[$fieldName];
if ($values === true) {
$params[$fieldName] = $checkbox->hasAttribute('value') ? $checkbox->getAttribute('value') : 'on';
$chFoundByName[$fieldName] = $pos + 1;
} elseif (is_array($values)) {
if ($values[$pos] === true) {
$params[$fieldName][$pos] = $checkbox->hasAttribute('value') ? $checkbox->getAttribute('value') : 'on';
$chFoundByName[$fieldName] = $pos + 1;
} else {
array_splice($params[$fieldName], $pos, 1);
}
} else {
unset($params[$fieldName]);
}
}
return $params;
}
/**
* Submits the form currently selected in the passed SymfonyCrawler, after
* setting any values passed in $params and setting the value of the
* passed button name.
*
* @param SymfonyCrawler $frmCrawl the form to submit
* @param array $params additional parameter values to set on the
* form
* @param string|null $button the name of a submit button in the form
*/
protected function proceedSubmitForm(Crawler $frmCrawl, array $params, ?string $button = null): void
{
$url = null;
$form = $this->getFormFor($frmCrawl);
$defaults = $this->getFormValuesFor($form);
$merged = array_merge($defaults, $params);
$requestParams = $this->setCheckboxBoolValues($frmCrawl, $merged);
if (!empty($button)) {
$btnCrawl = $frmCrawl->filterXPath(sprintf(
'//*[not(@disabled) and @type="submit" and @name=%s]',
SymfonyCrawler::xpathLiteral($button)
));
if (count($btnCrawl) > 0) {
$requestParams[$button] = $btnCrawl->attr('value');
$formaction = $btnCrawl->attr('formaction');
if ($formaction) {
$url = $formaction;
}
}
}
if ($url === null) {
$url = $this->getFormUrl($frmCrawl);
}
if (strcasecmp($form->getMethod(), 'GET') === 0) {
$url = Uri::mergeUrls($url, '?' . http_build_query($requestParams));
}
$url = preg_replace('#\#.*#', '', $url);
$this->debugSection('Uri', $url);
$this->debugSection('Method', $form->getMethod());
$this->debugSection('Parameters', $requestParams);
$requestParams= $this->getFormPhpValues($requestParams);
$this->crawler = $this->clientRequest(
$form->getMethod(),
$url,
$requestParams,
$form->getPhpFiles()
);
$this->forms = [];
}
public function submitForm($selector, array $params, ?string $button = null): void
{
$form = $this->match($selector)->first();
if (count($form) === 0) {
throw new ElementNotFound($this->stringifySelector($selector), 'Form');
}
$this->proceedSubmitForm($form, $params, $button);
}
/**
* Returns an absolute URL for the passed URI with the current URL
* as the base path.
*
* @param string $uri the absolute or relative URI
* @return string the absolute URL
* @throws TestRuntimeException if either the current
* URL or the passed URI can't be parsed
*/
protected function getAbsoluteUrlFor(string $uri): string
{
$currentUrl = $this->getRunningClient()->getHistory()->current()->getUri();
if (empty($uri) || str_starts_with($uri, '#')) {
return $currentUrl;
}
return Uri::mergeUrls($currentUrl, $uri);
}
/**
* Returns the form action's absolute URL.
*
* @throws TestRuntimeException if either the current
* URL or the URI of the form's action can't be parsed
*/
protected function getFormUrl(Crawler $form): string
{
$action = $form->form()->getUri();
return $this->getAbsoluteUrlFor($action);
}
/**
* Returns a crawler Form object for the form pointed to by the
* passed SymfonyCrawler.
*
* The returned form is an independent Crawler created to take care
* of the following issues currently experienced by Crawler's form
* object:
* - input fields disabled at a higher level (e.g. by a surrounding
* fieldset) still return values
* - Codeception expects an empty value to match an unselected
* select box.
*
* The function clones the crawler's node and creates a new crawler
* because it destroys or adds to the DOM for the form to achieve
* the desired functionality. Other functions simply querying the
* DOM wouldn't expect them.
*
* @param SymfonyCrawler $form the form
*/
private function getFormFromCrawler(Crawler $form): SymfonyForm
{
$fakeDom = new DOMDocument();
$fakeDom->appendChild($fakeDom->importNode($form->getNode(0), true));
//add fields having form attribute with id of this form
$formId = $form->attr('id');
if ($formId !== null) {
$fakeForm = $fakeDom->firstChild;
$topParent = $this->getAncestorsFor($form)->last();
$fieldsByFormAttribute = $topParent->filter(
sprintf('input[form=%s],select[form=%s],textarea[form=%s]', $formId, $formId, $formId)
);
foreach ($fieldsByFormAttribute as $field) {
$fakeForm->appendChild($fakeDom->importNode($field, true));
}
}
$node = $fakeDom->documentElement;
$action = $this->getFormUrl($form);
$cloned = new SymfonyCrawler($node, $action, $this->getBaseUrl());
$shouldDisable = $cloned->filter(
'input:disabled:not([disabled]),select option:disabled,select optgroup:disabled option:not([disabled]),textarea:disabled:not([disabled]),select:disabled:not([disabled])'
);
foreach ($shouldDisable as $field) {
$field->parentNode->removeChild($field);
}
return $cloned->form();
}
/**
* Returns the DomCrawler\Form object for the form pointed to by
* $node or its closes form parent.
*/
protected function getFormFor(Crawler $node): SymfonyForm
{
if (strcasecmp($node->first()->getNode(0)->tagName, 'form') === 0) {
$form = $node->first();
} else {
$form = $this->getAncestorsFor($node)->filter('form')->first();
}
if (!$form) {
$this->fail('The selected node is not a form and does not have a form ancestor.');
}
$identifier = $form->attr('id') ?: $form->attr('action');
if (!isset($this->forms[$identifier])) {
$this->forms[$identifier] = $this->getFormFromCrawler($form);
}
return $this->forms[$identifier];
}
/**
* Returns the ancestors of the passed SymfonyCrawler.
*
* symfony/dom-crawler deprecated parents() in favor of ancestors()
* This provides backward compatibility with < 5.3.0-BETA-1
*
* @param SymfonyCrawler $crawler the crawler
* @return SymfonyCrawler the ancestors
*/
private function getAncestorsFor(SymfonyCrawler $crawler): SymfonyCrawler
{
if (method_exists($crawler, 'ancestors')) {
return $crawler->ancestors();
}
return $crawler->parents();
}
/**
* Returns an array of name => value pairs for the passed form.
*
* For form fields containing a name ending in [], an array is
* created out of all field values with the given name.
*
* @param SymfonyForm $form the form
* @return array an array of name => value pairs
*/
protected function getFormValuesFor(SymfonyForm $form): array
{
$formNodeCrawler = new Crawler($form->getFormNode());
$values = [];
$fields = $form->all();
foreach ($fields as $field) {
if ($field instanceof FileFormField || $field->isDisabled()) {
continue;
}
if (!$field->hasValue()) {
// if unchecked a checkbox and if there is hidden input with same name to submit unchecked value
$hiddenInput = $formNodeCrawler->filter('input[type=hidden][name="'.$field->getName().'"]:not([disabled])');
if (count($hiddenInput) === 0) {
continue;
} else {
// there might be multiple hidden input with same name, but we will only grab last one's value
$fieldValue = $hiddenInput->last()->attr('value');
}
} else {
$fieldValue = $field->getValue();
}
$fieldName = $this->getSubmissionFormFieldName($field->getName());
if (str_ends_with($field->getName(), '[]')) {
if (!isset($values[$fieldName])) {
$values[$fieldName] = [];
}
$values[$fieldName][] = $fieldValue;
} else {
$values[$fieldName] = $fieldValue;
}
}
return $values;
}
public function fillField($field, $value): void
{
$value = (string) $value;
$input = $this->getFieldByLabelOrCss($field);
$form = $this->getFormFor($input);
$name = $input->attr('name');
$dynamicField = $input->getNode(0)->tagName === 'textarea'
? new TextareaFormField($input->getNode(0))
: new InputFormField($input->getNode(0));
$formField = $this->matchFormField($name, $form, $dynamicField);
$formField->setValue($value);
$input->getNode(0)->setAttribute('value', htmlspecialchars($value));
$inputGetNode = $input->getNode(0);
if ($inputGetNode->tagName === 'textarea') {
$input->getNode(0)->nodeValue = htmlspecialchars($value);
}
}
protected function getFieldsByLabelOrCss($field): SymfonyCrawler
{
$input = null;
if (is_array($field)) {
$input = $this->strictMatch($field);
if (count($input) === 0) {
throw new ElementNotFound($field);
}
return $input;
}
// by label
$label = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]', SymfonyCrawler::xpathLiteral($field))]);
if (count($label) > 0) {
$label = $label->first();
if ($label->attr('for')) {
$input = $this->strictMatch(['id' => $label->attr('for')]);
} else {
$input = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]//input', SymfonyCrawler::xpathLiteral($field))]);
}
}
// by name
if (!isset($input)) {
$input = $this->strictMatch(['name' => $field]);
}
// by CSS and XPath
if (count($input) === 0) {
$input = $this->match($field);
}
if (count($input) === 0) {
throw new ElementNotFound($field, 'Form field by Label or CSS');
}
return $input;
}
protected function getFieldByLabelOrCss($field): SymfonyCrawler
{
$input = $this->getFieldsByLabelOrCss($field);
return $input->first();
}
public function selectOption($select, $option): void
{
$field = $this->getFieldByLabelOrCss($select);
$form = $this->getFormFor($field);
$fieldName = $this->getSubmissionFormFieldName($field->attr('name'));
if (is_array($option)) {
if (!isset($option[0])) { // strict option locator
$form[$fieldName]->select($this->matchOption($field, $option));
codecept_debug($option);
return;
}
$options = [];
foreach ($option as $opt) {
$options[] = $this->matchOption($field, $opt);
}
$form[$fieldName]->select($options);
return;
}
$dynamicField = new ChoiceFormField($field->getNode(0));
$formField = $this->matchFormField($fieldName, $form, $dynamicField);
$selValue = $this->matchOption($field, $option);
if (is_array($formField)) {
foreach ($formField as $field) {
$values = $field->availableOptionValues();
foreach ($values as $val) {
if ($val === $option) {
$field->select($selValue);
return;
}
}
}
return;
}
$formField->select((string) $this->matchOption($field, $option));
}
/**
* @return mixed
*/
protected function matchOption(Crawler $field, string|array $option)
{
if (isset($option['value'])) {
return $option['value'];
}
if (isset($option['text'])) {
$option = $option['text'];
}
$options = $field->filterXPath(sprintf('//option[text()=normalize-space("%s")]|//input[@type="radio" and @value=normalize-space("%s")]', $option, $option));
if ($options->count() !== 0) {
$firstMatchingDomNode = $options->getNode(0);
if ($firstMatchingDomNode->tagName === 'option') {
$firstMatchingDomNode->setAttribute('selected', 'selected');
} else {
$firstMatchingDomNode->setAttribute('checked', 'checked');
}
$valueAttribute = $options->first()->attr('value');
//attr() returns null when option has no value attribute
if ($valueAttribute !== null) {
return $valueAttribute;
}
return $options->first()->text();
}
return $option;
}
public function checkOption($option): void
{
$this->proceedCheckOption($option)->tick();
}
public function uncheckOption($option): void
{
$this->proceedCheckOption($option)->untick();
}
/**
* @param string|string[] $option
*/
protected function proceedCheckOption(string|array $option): ChoiceFormField
{
$form = $this->getFormFor($field = $this->getFieldByLabelOrCss($option));
$name = $field->attr('name');
if ($field->getNode(0) === null) {
throw new TestRuntimeException("Form field {$name} is not located");
}
// If the name is an array than we compare objects to find right checkbox
$formField = $this->matchFormField($name, $form, new ChoiceFormField($field->getNode(0)));
$field->getNode(0)->setAttribute('checked', 'checked');
if (!$formField instanceof ChoiceFormField) {
throw new TestRuntimeException("Form field {$name} is not a checkable");
}
return $formField;
}
public function attachFile($field, string $filename): void
{
$form = $this->getFormFor($field = $this->getFieldByLabelOrCss($field));
$filePath = codecept_data_dir() . $filename;
if (!file_exists($filePath)) {
throw new InvalidArgumentException("File does not exist: {$filePath}");
}
if (!is_readable($filePath)) {
throw new InvalidArgumentException("File is not readable: {$filePath}");
}
$name = $field->attr('name');
$formField = $this->matchFormField($name, $form, new FileFormField($field->getNode(0)));
if (is_array($formField)) {
$this->fail("Field {$name} is ignored on upload, field {$name} is treated as array.");
}
$formField->upload($filePath);
}
/**
* Sends an ajax GET request with the passed parameters.
* See `sendAjaxPostRequest()`
*/
public function sendAjaxGetRequest(string $uri, array $params = []): void
{
$this->sendAjaxRequest('GET', $uri, $params);
}
/**
* Sends an ajax POST request with the passed parameters.
* The appropriate HTTP header is added automatically:
* `X-Requested-With: XMLHttpRequest`
* Example:
* ``` php
* <?php
* $I->sendAjaxPostRequest('/add-task', ['task' => 'lorem ipsum']);
* ```
* Some frameworks (e.g. Symfony) create field names in the form of an "array":
* `<input type="text" name="form[task]">`
* In this case you need to pass the fields like this:
* ``` php
* <?php
* $I->sendAjaxPostRequest('/add-task', ['form' => [
* 'task' => 'lorem ipsum',
* 'category' => 'miscellaneous',
* ]]);
* ```
*/
public function sendAjaxPostRequest(string $uri, array $params = []): void
{
$this->sendAjaxRequest('POST', $uri, $params);
}
/**
* Sends an ajax request, using the passed HTTP method.
* See `sendAjaxPostRequest()`
* Example:
* ``` php
* <?php
* $I->sendAjaxRequest('PUT', '/posts/7', ['title' => 'new title']);
* ```
*/
public function sendAjaxRequest(string $method, string $uri, array $params = []): void
{
$this->clientRequest($method, $uri, $params, [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], null, false);
}
/**
* @param mixed $url
*/
protected function debugResponse($url): void
{
$this->debugSection('Page', $url);
$this->debugSection('Response', $this->getResponseStatusCode());
$this->debugSection('Request Cookies', $this->getRunningClient()->getInternalRequest()->getCookies());
$this->debugSection('Response Headers', $this->getRunningClient()->getInternalResponse()->getHeaders());
}
public function makeHtmlSnapshot(?string $name = null): void
{
if (empty($name)) {
$name = uniqid(date("Y-m-d_H-i-s_"), true);
}
$debugDir = codecept_output_dir() . 'debug';
if (!is_dir($debugDir)) {
mkdir($debugDir);
}
$fileName = $debugDir . DIRECTORY_SEPARATOR . $name . '.html';
$this->_savePageSource($fileName);
$this->debugSection('Snapshot Saved', "file://{$fileName}");
}
public function _getResponseStatusCode()
{
return $this->getResponseStatusCode();
}
protected function getResponseStatusCode()
{
// depending on Symfony version
$response = $this->getRunningClient()->getInternalResponse();
if (method_exists($response, 'getStatusCode')) {
return $response->getStatusCode();
}
if (method_exists($response, 'getStatus')) {
return $response->getStatus();
}
return "N/A";
}
/**
* @param string|string[] $selector
*/
protected function match(string|array $selector): SymfonyCrawler
{
if (is_array($selector)) {
return $this->strictMatch($selector);
}
if (Locator::isCSS($selector)) {
return $this->getCrawler()->filter($selector);
}
if (Locator::isXPath($selector)) {
return $this->getCrawler()->filterXPath($selector);
}
throw new MalformedLocatorException($selector, 'XPath or CSS');
}
/**
* @param string[] $by
* @throws TestRuntimeException
*/
protected function strictMatch(array $by): SymfonyCrawler
{
$type = key($by);
$locator = $by[$type];
return match ($type) {
'id' => $this->filterByCSS(sprintf('#%s', $locator)),
'name' => $this->filterByXPath(sprintf('.//*[@name=%s]', SymfonyCrawler::xpathLiteral($locator))),
'css' => $this->filterByCSS($locator),
'xpath' => $this->filterByXPath($locator),
'link' => $this->filterByXPath(sprintf('.//a[.=%s or contains(./@title, %s)]', SymfonyCrawler::xpathLiteral($locator), SymfonyCrawler::xpathLiteral($locator))),
'class' => $this->filterByCSS(".{$locator}"),
default => throw new TestRuntimeException(
"Locator type '{$by}' is not defined. Use either: xpath, css, id, link, class, name"
),
};
}
protected function filterByAttributes(Crawler $nodes, array $attributes)
{
foreach ($attributes as $attr => $val) {
$nodes = $nodes->reduce(
static fn(Crawler $node): bool => $node->attr($attr) === $val
);
}
return $nodes;
}
public function grabTextFrom($cssOrXPathOrRegex): mixed
{
if (is_string($cssOrXPathOrRegex) && @preg_match($cssOrXPathOrRegex, $this->client->getInternalResponse()->getContent(), $matches)) {
return $matches[1];
}
$nodes = $this->match($cssOrXPathOrRegex);
if ($nodes->count() !== 0) {
return $nodes->first()->text();
}
throw new ElementNotFound($cssOrXPathOrRegex, 'Element that matches CSS or XPath or Regex');
}
public function grabAttributeFrom($cssOrXpath, string $attribute): mixed
{
$nodes = $this->match($cssOrXpath);
if ($nodes->count() === 0) {
throw new ElementNotFound($cssOrXpath, 'Element that matches CSS or XPath');
}
return $nodes->first()->attr($attribute);
}
public function grabMultiple($cssOrXpath, ?string $attribute = null): array
{
$result = [];
$nodes = $this->match($cssOrXpath);
foreach ($nodes as $node) {
$result[] = $attribute !== null ? $node->getAttribute($attribute) : $node->textContent;
}
return $result;
}
public function grabValueFrom($field): mixed
{
$nodes = $this->match($field);
if ($nodes->count() === 0) {
throw new ElementNotFound($field, 'Field');
}
if ($nodes->filter('textarea')->count() !== 0) {
return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue();
}
$input = $nodes->filter('input');
if ($input->count() !== 0) {
return $this->getInputValue($input);
}
if ($nodes->filter('select')->count() !== 0) {
$field = new ChoiceFormField($nodes->filter('select')->getNode(0));
$options = $nodes->filter('option[selected]');
$values = [];
foreach ($options as $option) {
$values[] = $option->getAttribute('value');
}
if (!$field->isMultiple()) {
return reset($values);
}
return $values;
}
$this->fail("Element {$nodes} is not a form field or does not contain a form field");
}
public function setCookie($name, $val, $params = [])
{
$cookies = $this->client->getCookieJar();
$params = array_merge($this->defaultCookieParameters, $params);
$expires = $params['expiry'] ?? null; // WebDriver compatibility
$expires = isset($params['expires']) && !$expires ? $params['expires'] : null;
$path = $params['path'] ?? null;
$domain = $params['domain'] ?? '';
$secure = $params['secure'] ?? false;
$httpOnly = $params['httpOnly'] ?? true;
$encodedValue = $params['encodedValue'] ?? false;
$cookies->set(new Cookie($name, $val, $expires, $path, $domain, $secure, $httpOnly, $encodedValue));
$this->debugCookieJar();
}
public function grabCookie(string $cookie, array $params = []): mixed
{
$params = array_merge($this->defaultCookieParameters, $params);
$this->debugCookieJar();
$cookies = $this->getRunningClient()->getCookieJar()->get($cookie, $params['path'], $params['domain']);
if ($cookies === null) {
return null;
}
return $cookies->getValue();
}
/**
* Grabs current page source code.
*
* @throws \Codeception\Exception\ModuleException if no page was opened.
* @return string Current page source code.
*/
public function grabPageSource(): string
{
return $this->_getResponseContent();
}
public function seeCookie($cookie, $params = [])
{
$params = array_merge($this->defaultCookieParameters, $params);
$this->debugCookieJar();
$this->assertNotNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain']));
}
public function dontSeeCookie($cookie, $params = [])
{
$params = array_merge($this->defaultCookieParameters, $params);
$this->debugCookieJar();
$this->assertNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain']));
}
public function resetCookie($cookie, $params = [])
{
$params = array_merge($this->defaultCookieParameters, $params);
$this->client->getCookieJar()->expire($cookie, $params['path'], $params['domain']);
$this->debugCookieJar();
}
private function stringifySelector($selector): string
{
if (is_array($selector)) {
return trim(json_encode($selector, JSON_THROW_ON_ERROR), '{}');
}
return $selector;
}
public function seeElement($selector, array $attributes = []): void
{
$nodes = $this->match($selector);
$selector = $this->stringifySelector($selector);
if (!empty($attributes)) {
$nodes = $this->filterByAttributes($nodes, $attributes);
$selector .= "' with attribute(s) '" . trim(json_encode($attributes, JSON_THROW_ON_ERROR), '{}');
}
$this->assertDomContains($nodes, $selector);
}
public function dontSeeElement($selector, array $attributes = []): void
{
$nodes = $this->match($selector);
$selector = $this->stringifySelector($selector);
if (!empty($attributes)) {
$nodes = $this->filterByAttributes($nodes, $attributes);
$selector .= "' with attribute(s) '" . trim(json_encode($attributes, JSON_THROW_ON_ERROR), '{}');
}
$this->assertDomNotContains($nodes, $selector);
}
public function seeNumberOfElements($selector, $expected): void
{
$counted = count($this->match($selector));
if (is_array($expected)) {
[$floor, $ceil] = $expected;
$this->assertTrue(
$floor <= $counted && $ceil >= $counted,
'Number of elements counted differs from expected range'
);
} else {
$this->assertSame(
$expected,
$counted,
'Number of elements counted differs from expected number'
);
}
}
public function seeOptionIsSelected($selector, $optionText)
{
$selected = $this->matchSelectedOption($selector);
$this->assertDomContains($selected, 'selected option');
//If element is radio then we need to check value
$value = $selected->getNode(0)->tagName === 'option'
? $selected->text()
: $selected->getNode(0)->getAttribute('value');
$this->assertSame($optionText, $value);
}
public function dontSeeOptionIsSelected($selector, $optionText)
{
$selected = $this->matchSelectedOption($selector);
if ($selected->count() === 0) {
$this->assertSame(0, $selected->count());
return;
}
//If element is radio then we need to check value
$value = $selected->getNode(0)->tagName === 'option'
? $selected->text()
: $selected->getNode(0)->getAttribute('value');
$this->assertNotSame($optionText, $value);
}
protected function matchSelectedOption($select): SymfonyCrawler
{
$nodes = $this->getFieldsByLabelOrCss($select);
$selectedOptions = $nodes->filter('option[selected],input:checked');
if ($selectedOptions->count() === 0) {
$selectedOptions = $nodes->filter('option,input')->first();
}
return $selectedOptions;
}
/**
* Asserts that current page has 404 response status code.
*/
public function seePageNotFound(): void
{
$this->seeResponseCodeIs(404);
}
/**
* Checks that response code is equal to value provided.
*
* ```php
* <?php
* $I->seeResponseCodeIs(200);
*
* // recommended \Codeception\Util\HttpCode
* $I->seeResponseCodeIs(\Codeception\Util\HttpCode::OK);
* ```
*/
public function seeResponseCodeIs(int $code): void
{
$failureMessage = sprintf(
'Expected HTTP Status Code: %s. Actual Status Code: %s',
HttpCode::getDescription($code),
HttpCode::getDescription($this->getResponseStatusCode())
);
$this->assertSame($code, $this->getResponseStatusCode(), $failureMessage);
}
/**
* Checks that response code is between a certain range. Between actually means [from <= CODE <= to]
*/
public function seeResponseCodeIsBetween(int $from, int $to): void
{
$failureMessage = sprintf(
'Expected HTTP Status Code between %s and %s. Actual Status Code: %s',
HttpCode::getDescription($from),
HttpCode::getDescription($to),
HttpCode::getDescription($this->getResponseStatusCode())
);
$this->assertGreaterThanOrEqual($from, $this->getResponseStatusCode(), $failureMessage);
$this->assertLessThanOrEqual($to, $this->getResponseStatusCode(), $failureMessage);
}
/**
* Checks that response code is equal to value provided.
*
* ```php
* <?php
* $I->dontSeeResponseCodeIs(200);
*
* // recommended \Codeception\Util\HttpCode
* $I->dontSeeResponseCodeIs(\Codeception\Util\HttpCode::OK);
* ```
*/
public function dontSeeResponseCodeIs(int $code): void
{
$failureMessage = sprintf(
'Expected HTTP status code other than %s',
HttpCode::getDescription($code)
);
$this->assertNotSame($code, $this->getResponseStatusCode(), $failureMessage);
}
/**
* Checks that the response code 2xx
*/
public function seeResponseCodeIsSuccessful(): void
{
$this->seeResponseCodeIsBetween(200, 299);
}
/**
* Checks that the response code 3xx
*/
public function seeResponseCodeIsRedirection(): void
{
$this->seeResponseCodeIsBetween(300, 399);
}
/**
* Checks that the response code is 4xx
*/
public function seeResponseCodeIsClientError(): void
{
$this->seeResponseCodeIsBetween(400, 499);
}
/**
* Checks that the response code is 5xx
*/
public function seeResponseCodeIsServerError(): void
{
$this->seeResponseCodeIsBetween(500, 599);
}
public function seeInTitle($title)
{
$nodes = $this->getCrawler()->filter('title');
if ($nodes->count() === 0) {
throw new ElementNotFound("<title>", "Tag");
}
$this->assertStringContainsString($title, $nodes->first()->text(), "page title contains {$title}");
}
public function dontSeeInTitle($title)
{
$nodes = $this->getCrawler()->filter('title');
if ($nodes->count() === 0) {
$this->assertTrue(true);
return;
}
$this->assertStringNotContainsString($title, $nodes->first()->text(), "page title contains {$title}");
}
protected function assertDomContains($nodes, string $message, string $text = ''): void
{
$constraint = new CrawlerConstraint($text, $this->_getCurrentUri());
$this->assertThat($nodes, $constraint, $message);
}
protected function assertDomNotContains($nodes, string $message, string $text = ''): void
{
$constraint = new CrawlerNotConstraint($text, $this->_getCurrentUri());
$this->assertThat($nodes, $constraint, $message);
}
protected function assertPageContains(string $needle, string $message = ''): void
{
$constraint = new PageConstraint($needle, $this->_getCurrentUri());
$this->assertThat(
$this->getNormalizedResponseContent(),
$constraint,
$message
);
}
protected function assertPageNotContains(string $needle, string $message = ''): void
{
$constraint = new PageConstraint($needle, $this->_getCurrentUri());
$this->assertThatItsNot(
$this->getNormalizedResponseContent(),
$constraint,
$message
);
}
protected function assertPageSourceContains(string $needle, string $message = ''): void
{
$constraint = new PageConstraint($needle, $this->_getCurrentUri());
$this->assertThat(
$this->_getResponseContent(),
$constraint,
$message
);
}
protected function assertPageSourceNotContains(string $needle, string $message = ''): void
{
$constraint = new PageConstraint($needle, $this->_getCurrentUri());
$this->assertThatItsNot(
$this->_getResponseContent(),
$constraint,
$message
);
}
/**
* @param array|object $form
*/
protected function matchFormField(string $name, $form, FormField $dynamicField): FormField|array
{
if (!str_ends_with($name, '[]')) {
return $form[$name];
}
$name = substr($name, 0, -2);
/** @var FormField $item */
foreach ($form[$name] as $item) {
if ($item == $dynamicField) {
return $item;
}
}
throw new TestRuntimeException("None of form fields by {$name}[] were not matched");
}
protected function filterByCSS(string $locator): SymfonyCrawler
{
if (!Locator::isCSS($locator)) {
throw new MalformedLocatorException($locator, 'css');
}
return $this->getCrawler()->filter($locator);
}
protected function filterByXPath(string $locator): SymfonyCrawler
{
if (!Locator::isXPath($locator)) {
throw new MalformedLocatorException($locator, 'xpath');
}
return $this->getCrawler()->filterXPath($locator);
}
protected function getFormPhpValues(array $requestParams): array
{
foreach ($requestParams as $name => $value) {
$qs = http_build_query([$name => $value]);
if (!empty($qs)) {
// If the field's name is of the form of "array[key]",
// we'll remove it from the request parameters
// and set the "array" key instead which will contain the actual array.
if (strpos($name, '[') && strpos($name, ']') > strpos($name, '[')) {
unset($requestParams[$name]);
}
parse_str($qs, $expandedValue);
$varName = substr($name, 0, strlen((string)key($expandedValue)));
$requestParams = array_replace_recursive($requestParams, [$varName => current($expandedValue)]);
}
}
return $requestParams;
}
protected function redirectIfNecessary(SymfonyCrawler $result, int $maxRedirects, int $redirectCount): SymfonyCrawler
{
$locationHeader = $this->client->getInternalResponse()->getHeader('Location');
$statusCode = $this->getResponseStatusCode();
if ($locationHeader && $statusCode >= 300 && $statusCode < 400) {
if ($redirectCount === $maxRedirects) {
throw new LogicException(sprintf(
'The maximum number (%d) of redirections was reached.',
$maxRedirects
));
}
$this->debugSection('Redirecting to', $locationHeader);
$result = $this->client->followRedirect();
$this->debugResponse($locationHeader);
return $this->redirectIfNecessary($result, $maxRedirects, $redirectCount + 1);
}
$this->client->followRedirects(true);
return $result;
}
/**
* Switch to iframe or frame on the page.
*
* Example:
* ``` html
* <iframe name="another_frame" src="http://example.com">
* ```
*
* ``` php
* <?php
* # switch to iframe
* $I->switchToIframe("another_frame");
* ```
*/
public function switchToIframe(string $name): void
{
$iframe = $this->match("iframe[name={$name}]")->first();
if (count($iframe) === 0) {
$iframe = $this->match("frame[name={$name}]")->first();
}
if (count($iframe) === 0) {
throw new ElementNotFound("name={$name}", 'Iframe');
}
$uri = $iframe->getNode(0)->getAttribute('src');
$this->amOnPage($uri);
}
/**
* Moves back in history.
*
* @param int $numberOfSteps (default value 1)
*/
public function moveBack(int $numberOfSteps = 1): void
{
$request = null;
if (!is_int($numberOfSteps) || $numberOfSteps < 1) {
throw new InvalidArgumentException('numberOfSteps must be positive integer');
}
try {
$history = $this->getRunningClient()->getHistory();
for ($i = $numberOfSteps; $i > 0; --$i) {
$request = $history->back();
}
} catch (LogicException $exception) {
throw new InvalidArgumentException(
sprintf(
'numberOfSteps is set to %d, but there are only %d previous steps in the history',
$numberOfSteps,
$numberOfSteps - $i
), $exception->getCode(), $exception);
}
$this->_loadPage(
$request->getMethod(),
$request->getUri(),
$request->getParameters(),
$request->getFiles(),
$request->getServer(),
$request->getContent()
);
}
protected function debugCookieJar(): void
{
$cookies = $this->client->getCookieJar()->all();
$cookieStrings = array_map('strval', $cookies);
$this->debugSection('Cookie Jar', $cookieStrings);
}
protected function setCookiesFromOptions()
{
if (isset($this->config['cookies']) && is_array($this->config['cookies']) && !empty($this->config['cookies'])) {
$domain = parse_url($this->config['url'], PHP_URL_HOST);
$cookieJar = $this->client->getCookieJar();
foreach ($this->config['cookies'] as &$cookie) {
if (!is_array($cookie) || !array_key_exists('Name', $cookie) || !array_key_exists('Value', $cookie)) {
throw new InvalidArgumentException('Cookies must have at least Name and Value attributes');
}
if (!isset($cookie['Domain'])) {
$cookie['Domain'] = $domain;
}
if (!isset($cookie['Expires'])) {
$cookie['Expires'] = null;
}
if (!isset($cookie['Path'])) {
$cookie['Path'] = '/';
}
if (!isset($cookie['Secure'])) {
$cookie['Secure'] = false;
}
if (!isset($cookie['HttpOnly'])) {
$cookie['HttpOnly'] = false;
}
$cookieJar->set(new Cookie(
$cookie['Name'],
$cookie['Value'],
$cookie['Expires'],
$cookie['Path'],
$cookie['Domain'],
$cookie['Secure'],
$cookie['HttpOnly']
));
}
}
}
protected function getNormalizedResponseContent(): string
{
$content = $this->_getResponseContent();
// Since strip_tags has problems with JS code that contains
// an <= operator the script tags have to be removed manually first.
$content = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content);
$content = strip_tags($content);
$content = html_entity_decode($content, ENT_QUOTES);
$content = str_replace("\n", ' ', $content);
return preg_replace('#\s{2,}#', ' ', $content);
}
/**
* Sets SERVER parameters valid for all next requests.
* this will remove old ones.
*
* ```php
* $I->setServerParameters([]);
* ```
*/
public function setServerParameters(array $params): void
{
$this->client->setServerParameters($params);
}
/**
* Sets SERVER parameter valid for all next requests.
*
* ```php
* $I->haveServerParameter('name', 'value');
* ```
*/
public function haveServerParameter(string $name, string $value): void
{
$this->client->setServerParameter($name, $value);
}
/**
* Prevents automatic redirects to be followed by the client.
*
* ```php
* <?php
* $I->stopFollowingRedirects();
* ```
*/
public function stopFollowingRedirects(): void
{
$this->client->followRedirects(false);
}
/**
* Enables automatic redirects to be followed by the client.
*
* ```php
* <?php
* $I->startFollowingRedirects();
* ```
*/
public function startFollowingRedirects(): void
{
$this->client->followRedirects(true);
}
/**
* Follow pending redirect if there is one.
*
* ```php
* <?php
* $I->followRedirect();
* ```
*/
public function followRedirect(): void
{
$this->client->followRedirect();
}
/**
* Sets the maximum number of redirects that the Client can follow.
*
* ```php
* <?php
* $I->setMaxRedirects(2);
* ```
*/
public function setMaxRedirects(int $maxRedirects): void
{
$this->client->setMaxRedirects($maxRedirects);
}
}