diff --git a/src/Codeception/Lib/InnerBrowser.php b/src/Codeception/Lib/InnerBrowser.php index f0c52e5..502a133 100644 --- a/src/Codeception/Lib/InnerBrowser.php +++ b/src/Codeception/Lib/InnerBrowser.php @@ -1,4 +1,5 @@ null, + 'path' => '/', + 'domain' => '', + 'secure' => false + ]; + /** - * @api - * @var \Symfony\Component\BrowserKit\AbstractBrowser + * @var array|Form[] */ - public $client; + protected $forms = []; + + protected $internalDomains; /** - * @var array|\Symfony\Component\DomCrawler\Form[] + * @api + * @var AbstractBrowser */ - protected $forms = []; + public $client; public $headers = []; - protected $defaultCookieParameters = ['expires' => null, 'path' => '/', 'domain' => '', 'secure' => false]; + /** + * Clicks the link or submits the form when the button is clicked + * @param DOMNode $node + * @return boolean clicked something + * @throws ModuleException|ExternalUrlException + */ + private function clickButton(DOMNode $node) + { + /** + * 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; + } + } + } - protected $internalDomains; + if (isset($form)) { + $buttonName = $node->getAttribute('name'); + if ($buttonName !== '') { + $formParams = [$buttonName => $node->getAttribute('value')]; + } else { + $formParams = []; + } + $this->proceedSubmitForm( + new Crawler($form, $this->getAbsoluteUrlFor($this->_getCurrentUri()), $this->getBaseUrl()), + $formParams + ); + return true; + } - private $baseUrl; + // 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'); + } - public function _failed(TestInterface $test, $fail) + private function getBaseUrl() { - try { - if (!$this->client || !$this->client->getInternalResponse()) { - return; - } - } catch (BadMethodCallException $e) { - //Symfony 5 throws exception if request() method threw an exception. - //The "request()" method must be called before "Symfony\Component\BrowserKit\AbstractBrowser::getInternalResponse()" - return; + return $this->baseUrl; + } + + /** + * @return Crawler + * @throws ModuleException + */ + private function getCrawler() + { + if (!$this->crawler) { + throw new ModuleException($this, 'Crawler is null. Perhaps you forgot to call "amOnPage"?'); } - $filename = preg_replace('~\W~', '.', Descriptor::getTestSignatureUnique($test)); + return $this->crawler; + } - $extensions = [ - 'application/json' => 'json', - 'text/xml' => 'xml', - 'application/xml' => 'xml', - 'text/plain' => 'txt' - ]; + /** + * Returns a crawler Form object for the form pointed to by the + * passed Crawler. + * + * 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 Crawler $form the form + * @return Form + * @throws ModuleException + */ + private function getFormFromCrawler(Crawler $form) + { + $fakeDom = new DOMDocument(); + $fakeDom->appendChild($fakeDom->importNode($form->getNode(0), true)); + $node = $fakeDom->documentElement; + $action = (string)$this->getFormUrl($form); + $cloned = new Crawler($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(); + } + /** + * @return AbstractBrowser + * @throws ModuleException + */ + private function getRunningClient() + { try { - $internalResponse = $this->client->getInternalResponse(); + 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 $e) { - $internalResponse = false; + //Symfony 5 + throw new ModuleException( + $this, + "Page not loaded. Use `\$I->amOnPage` (or hidden API methods `_request` and `_loadPage`) to open it" + ); } - - $responseContentType = $internalResponse ? $internalResponse->getHeader('content-type') : ''; - list($responseMimeType) = explode(';', $responseContentType); - - $extension = isset($extensions[$responseMimeType]) ? $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); + return $this->client; } - public function _after(TestInterface $test) + private function openHrefFromDomNode(DOMNode $node) { - $this->client = null; - $this->crawler = null; - $this->forms = []; - $this->headers = []; + $link = new Link($node, $this->getBaseUrl()); + $this->amOnPage(preg_replace('/#.*/', '', $link->getUri())); } - public function _conflicts() + /** + * @return string + * @throws ModuleException + */ + private function retrieveBaseUrl() { - return 'Codeception\Lib\Interfaces\Web'; + $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 _findElements($locator) + private function stringifySelector($selector) { - return $this->match($locator); + if (is_array($selector)) { + return trim(json_encode($selector), '{}'); + } + return $selector; } /** - * 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 - * 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. - * - * @api * @param $method * @param $uri * @param array $parameters * @param array $files * @param array $server * @param null $content - * @return mixed|Crawler - * @throws ExternalUrlException - * @see `_loadPage` + * @param bool $changeHistory + * @return mixed|Crawler|null + * @throws ExternalUrlException|ModuleException */ - public function _request( + protected function clientRequest( $method, $uri, array $parameters = [], array $files = [], array $server = [], - $content = null + $content = null, + $changeHistory = true ) { - $this->clientRequest($method, $uri, $parameters, $files, $server, $content, true); - 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 - * assertStringContainsString($text, $this->getModule('{{MODULE_NAME}}')->_getResponseContent(), "response contains"); - * } - * ?> - * ``` - * - * @api - * @return string - * @throws ModuleException - */ - public function _getResponseContent() - { - return (string)$this->getRunningClient()->getInternalResponse()->getContent(); - } - - protected function clientRequest($method, $uri, array $parameters = [], array $files = [], array $server = [], $content = null, $changeHistory = true) - { - $this->debugSection("Request Headers", $this->headers); + $this->debugSection('Request Headers', $this->headers); foreach ($this->headers as $header => $val) { // moved from REST module @@ -219,8 +300,12 @@ protected function clientRequest($method, $uri, array $parameters = [], array $f $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'); + $isFollowingRedirects = ReflectionHelper::readPrivateProperty( + $this->client, 'followRedirects', 'Symfony\Component\BrowserKit\Client' + ); + $maxRedirects = ReflectionHelper::readPrivateProperty( + $this->client, 'maxRedirects', 'Symfony\Component\BrowserKit\Client' + ); } if (!$isFollowingRedirects) { @@ -235,201 +320,70 @@ protected function clientRequest($method, $uri, array $parameters = [], array $f return $this->redirectIfNecessary($result, $maxRedirects, 0); } - protected function isInternalDomain($domain) + protected function assertDomContains($nodes, $message, $text = '') { - if ($this->internalDomains === null) { - $this->internalDomains = $this->getInternalDomains(); - } + $constraint = new CrawlerConstraint($text, $this->_getCurrentUri()); + $this->assertThat($nodes, $constraint, $message); + } - foreach ($this->internalDomains as $pattern) { - if (preg_match($pattern, $domain)) { - return true; - } - } - return false; + protected function assertDomNotContains($nodes, $message, $text = '') + { + $constraint = new CrawlerNotConstraint($text, $this->_getCurrentUri()); + $this->assertThat($nodes, $constraint, $message); } /** - * Opens a page with arbitrary request parameters. - * Useful for testing multi-step forms on a specific step. - * - * ```php - * getModule('{{MODULE_NAME}}')->_loadPage('POST', '/checkout/step2', ['order' => $orderId]); - * } - * ?> - * ``` - * - * @api - * @param $method - * @param $uri - * @param array $parameters - * @param array $files - * @param array $server - * @param null $content - */ - public function _loadPage( - $method, - $uri, - array $parameters = [], - array $files = [], - array $server = [], - $content = null - ) { - $this->crawler = $this->clientRequest($method, $uri, $parameters, $files, $server, $content); - $this->baseUrl = $this->retrieveBaseUrl(); - $this->forms = []; - } - - /** - * @return Crawler - * @throws ModuleException - */ - private function getCrawler() - { - if (!$this->crawler) { - throw new ModuleException($this, 'Crawler is null. Perhaps you forgot to call "amOnPage"?'); - } - return $this->crawler; - } - - private function getRunningClient() - { - 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 $e) { - //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($filename) - { - file_put_contents($filename, $this->_getResponseContent()); - } - - /** - * Authenticates user for HTTP_AUTH - * - * @param $username - * @param $password - */ - public function amHttpAuthenticated($username, $password) - { - $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 - * 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_Id' or 'Client_Id' - * - * ```php - * haveHttpHeader('Client_Id', 'Codeception'); - * ?> - * ``` - * - * @param string $name the name of the request header - * @param string $value the value to set it to for subsequent - * requests + * @param $needle + * @param string $message */ - public function haveHttpHeader($name, $value) + protected function assertPageContains($needle, $message = '') { - $name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name))))); - $this->headers[$name] = $value; + $constraint = new PageConstraint($needle, $this->_getCurrentUri()); + $this->assertThat( + $this->getNormalizedResponseContent(), + $constraint, + $message + ); } /** - * Deletes the header with the passed name. Subsequent requests - * will not have the deleted header in its request. - * - * Example: - * ```php - * haveHttpHeader('X-Requested-With', 'Codeception'); - * $I->amOnPage('test-headers.php'); - * // ... - * $I->deleteHeader('X-Requested-With'); - * $I->amOnPage('some-other-page.php'); - * ?> - * ``` - * - * @param string $name the name of the header to delete. + * @param $needle + * @param string $message */ - public function deleteHeader($name) + protected function assertPageNotContains($needle, $message = '') { - $name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name))))); - unset($this->headers[$name]); + $constraint = new PageConstraint($needle, $this->_getCurrentUri()); + $this->assertThatItsNot( + $this->getNormalizedResponseContent(), + $constraint, + $message + ); } - - public function amOnPage($page) + protected function assertPageSourceContains($needle, $message = '') { - $this->_loadPage('GET', $page); + $constraint = new PageConstraint($needle, $this->_getCurrentUri()); + $this->assertThat( + $this->_getResponseContent(), + $constraint, + $message + ); } - public function click($link, $context = null) + protected function assertPageSourceNotContains($needle, $message = '') { - if ($context) { - $this->crawler = $this->match($context); - } - - if (is_array($link)) { - $this->clickByLocator($link); - return; - } - - $anchor = $this->strictMatch(['link' => $link]); - if (!count($anchor)) { - $anchor = $this->getCrawler()->selectLink($link); - } - if (count($anchor)) { - $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 $e) { - throw new ElementNotFound("name=$link", "'$link' is invalid CSS and XPath selector and Link or Button"); - } + $constraint = new PageConstraint($needle, $this->_getCurrentUri()); + $this->assertThatItsNot( + $this->_getResponseContent(), + $constraint, + $message + ); } /** * @param $link * @return bool + * @throws ModuleException|ExternalUrlException */ protected function clickByLocator($link) { @@ -453,290 +407,452 @@ protected function clickByLocator($link) } } + protected function debugCookieJar() + { + $cookies = $this->client->getCookieJar()->all(); + $cookieStrings = array_map('strval', $cookies); + $this->debugSection('Cookie Jar', $cookieStrings); + } /** - * Clicks the link or submits the form when the button is clicked - * @param \DOMNode $node - * @return boolean clicked something + * @param $url + * @throws ModuleException */ - private function clickButton(\DOMNode $node) + protected function debugResponse($url) { - /** - * 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; - } - } - } + $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()); + } - if (isset($form)) { - $buttonName = $node->getAttribute('name'); - if ($buttonName !== '') { - $formParams = [$buttonName => $node->getAttribute('value')]; - } else { - $formParams = []; - } - $this->proceedSubmitForm( - new Crawler($form, $this->getAbsoluteUrlFor($this->_getCurrentUri()), $this->getBaseUrl()), - $formParams + protected function filterByAttributes(Crawler $nodes, array $attributes) + { + foreach ($attributes as $attr => $val) { + $nodes = $nodes->reduce( + static function (Crawler $node) use ($attr, $val) { + return $node->attr($attr) === $val; + } ); - return true; } + return $nodes; + } - // 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; - } + /** + * @param $locator + * @return Crawler + * @throws ModuleException + */ + protected function filterByCSS($locator) + { + if (!Locator::isCSS($locator)) { + throw new MalformedLocatorException($locator, 'css'); } - throw new TestRuntimeException('Button is not inside a link or a form'); + return $this->getCrawler()->filter($locator); } - private function openHrefFromDomNode(\DOMNode $node) + /** + * @param $locator + * @return Crawler + * @throws ModuleException + */ + protected function filterByXPath($locator) { - $link = new Link($node, $this->getBaseUrl()); - $this->amOnPage(preg_replace('/#.*/', '', $link->getUri())); + if (!Locator::isXPath($locator)) { + throw new MalformedLocatorException($locator, 'xpath'); + } + return $this->getCrawler()->filterXPath($locator); } - private function getBaseUrl() - { - return $this->baseUrl; + /** + * 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|ModuleException if either the current + * URL or the passed URI can't be parsed + */ + protected function getAbsoluteUrlFor($uri) + { + $currentUrl = $this->getRunningClient()->getHistory()->current()->getUri(); + if (empty($uri) || strpos($uri, '#') === 0) { + return $currentUrl; + } + return Uri::mergeUrls($currentUrl, $uri); } - private function retrieveBaseUrl() + /** + * @param $field + * @return object|Crawler + * @throws ModuleException + */ + protected function getFieldByLabelOrCss($field) { - $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); + $input = $this->getFieldsByLabelOrCss($field); + return $input->first(); } - public function see($text, $selector = null) + /** + * @param $field + * + * @return Crawler + * @throws ModuleException + */ + protected function getFieldsByLabelOrCss($field) { - if (!$selector) { - $this->assertPageContains($text); - return; + if (is_array($field)) { + $input = $this->strictMatch($field); + if (!count($input)) { + throw new ElementNotFound($field); + } + return $input; } - $nodes = $this->match($selector); - $this->assertDomContains($nodes, $this->stringifySelector($selector), $text); - } + // by label + $label = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]', Crawler::xpathLiteral($field))]); + if (count($label)) { + $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', Crawler::xpathLiteral($field))]); + } + } - public function dontSee($text, $selector = null) - { - if (!$selector) { - $this->assertPageNotContains($text); - return; + // by name + if (!isset($input)) { + $input = $this->strictMatch(['name' => $field]); } - $nodes = $this->match($selector); - $this->assertDomNotContains($nodes, $this->stringifySelector($selector), $text); - } + // by CSS and XPath + if (!count($input)) { + $input = $this->match($field); + } - public function seeInSource($raw) - { - $this->assertPageSourceContains($raw); - } + if (!count($input)) { + throw new ElementNotFound($field, 'Form field by Label or CSS'); + } - public function dontSeeInSource($raw) - { - $this->assertPageSourceNotContains($raw); + return $input; } - public function seeLink($text, $url = null) + /** + * Returns the DomCrawler\Form object for the form pointed to by + * $node or its closes form parent. + * + * @param Crawler $node + * @return Form + * @throws ModuleException + */ + protected function getFormFor(Crawler $node) { - $crawler = $this->getCrawler()->selectLink($text); - if ($crawler->count() === 0) { - $this->fail("No links containing text '$text' were found in page " . $this->_getCurrentUri()); + if (strcasecmp($node->first()->getNode(0)->tagName, 'form') === 0) { + $form = $node->first(); + } else { + $form = $node->parents()->filter('form')->first(); } - if ($url) { - $crawler = $crawler->filterXPath(sprintf('.//a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', Crawler::xpathLiteral($url))); - if ($crawler->count() === 0) { - $this->fail("No links containing text '$text' and URL '$url' were found in page " . $this->_getCurrentUri()); - } + if (!$form) { + $this->fail('The selected node is not a form and does not have a form ancestor.'); } - $this->assertTrue(true); - } - public function dontSeeLink($text, $url = '') - { - $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]', - Crawler::xpathLiteral($url)) - ); - if ($crawler->count() > 0) { - $this->fail("Link containing text '$text' and URL '$url' was found in page " . $this->_getCurrentUri()); + $identifier = $form->attr('id') ?: $form->attr('action'); + if (!isset($this->forms[$identifier])) { + $this->forms[$identifier] = $this->getFormFromCrawler($form); } + return $this->forms[$identifier]; } /** + * Returns the form action's absolute URL. + * + * @param Crawler $form * @return string - * @throws ModuleException + * @throws TestRuntimeException|ModuleException if either the current + * URL or the URI of the form's action can't be parsed */ - public function _getCurrentUri() - { - return Uri::retrieveUri($this->getRunningClient()->getHistory()->current()->getUri()); - } - - public function seeInCurrentUrl($uri) + protected function getFormUrl(Crawler $form) { - $this->assertStringContainsString($uri, $this->_getCurrentUri()); + $action = $form->form()->getUri(); + return $this->getAbsoluteUrlFor($action); } - public function dontSeeInCurrentUrl($uri) + /** + * 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 Form $form + * @return array an array of name => value pairs + */ + protected function getFormValuesFor(Form $form) { - $this->assertStringNotContainsString($uri, $this->_getCurrentUri()); + $values = []; + $fields = $form->all(); + foreach ($fields as $field) { + if ($field instanceof FileFormField || $field->isDisabled() || !$field->hasValue()) { + continue; + } + $fieldName = $this->getSubmissionFormFieldName($field->getName()); + if (substr($field->getName(), -2) === '[]') { + if (!isset($values[$fieldName])) { + $values[$fieldName] = []; + } + $values[$fieldName][] = $field->getValue(); + } else { + $values[$fieldName] = $field->getValue(); + } + } + return $values; } - public function seeCurrentUrlEquals($uri) + /** + * @param $requestParams + * @return array + */ + protected function getFormPhpValues($requestParams) { - $this->assertEquals(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/')); - } + 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]); + } - public function dontSeeCurrentUrlEquals($uri) - { - $this->assertNotEquals(rtrim($uri, '/'), rtrim($this->_getCurrentUri(), '/')); + parse_str($qs, $expandedValue); + $varName = substr($name, 0, strlen(key($expandedValue))); + $requestParams = array_replace_recursive($requestParams, [$varName => current($expandedValue)]); + } + } + return $requestParams; } - public function seeCurrentUrlMatches($uri) + /** + * Get the values of a set of input fields. + * + * @param Crawler $input + * @return array|string + */ + protected function getInputValue($input) { - $this->assertRegExp($uri, $this->_getCurrentUri()); - } + $inputType = $input->attr('type'); + if ($inputType === 'checkbox' || $inputType === 'radio') { + $values = []; - public function dontSeeCurrentUrlMatches($uri) - { - $this->assertNotRegExp($uri, $this->_getCurrentUri()); - } + foreach ($input->filter(':checked') as $checkbox) { + $values[] = $checkbox->getAttribute('value'); + } - public function grabFromCurrentUrl($uri = null) - { - 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 $values; } - return $matches[1]; - } - public function seeCheckboxIsChecked($checkbox) - { - $checkboxes = $this->getFieldsByLabelOrCss($checkbox); - $this->assertDomContains($checkboxes->filter('input[checked=checked]'), 'checkbox'); - } - - public function dontSeeCheckboxIsChecked($checkbox) - { - $checkboxes = $this->getFieldsByLabelOrCss($checkbox); - $this->assertEquals(0, $checkboxes->filter('input[checked=checked]')->count()); + return (new InputFormField($input->getNode(0)))->getValue(); } - public function seeInField($field, $value) + /** + * @return string + */ + protected function getNormalizedResponseContent() { - $nodes = $this->getFieldsByLabelOrCss($field); - $this->assert($this->proceedSeeInField($nodes, $value)); - } + $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('##is', '', $content); - public function dontSeeInField($field, $value) - { - $nodes = $this->getFieldsByLabelOrCss($field); - $this->assertNot($this->proceedSeeInField($nodes, $value)); - } + $content = strip_tags($content); + $content = html_entity_decode($content, ENT_QUOTES); + $content = str_replace("\n", ' ', $content); + $content = preg_replace('/\s{2,}/', ' ', $content); - public function seeInFormFields($formSelector, array $params) - { - $this->proceedSeeInFormFields($formSelector, $params, false); + return $content; } - public function dontSeeInFormFields($formSelector, array $params) + /** + * @return int|string + * @throws ModuleException + */ + protected function getResponseStatusCode() { - $this->proceedSeeInFormFields($formSelector, $params, true); + // 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"; } - protected function proceedSeeInFormFields($formSelector, array $params, $assertNot) + /** + * Get the values of a set of fields and also the texts of selected options. + * + * @param Crawler $nodes + * @return array|mixed|string + */ + protected function getValueAndTextFromField(Crawler $nodes) { - $form = $this->match($formSelector)->first(); - if ($form->count() === 0) { - throw new ElementNotFound($formSelector, 'Form'); + if ($nodes->filter('textarea')->count()) { + return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue(); } - $fields = []; - foreach ($params as $name => $values) { - $this->pushFormField($fields, $form, $name, $values); + $input = $nodes->filter('input'); + if ($input->count()) { + return $this->getInputValue($input); } - foreach ($fields as list($field, $values)) { - if (!is_array($values)) { - $values = [$values]; - } + if ($nodes->filter('select')->count()) { + $options = $nodes->filter('option[selected]'); + $values = []; - foreach ($values as $value) { - $ret = $this->proceedSeeInField($field, $value); - if ($assertNot) { - $this->assertNot($ret); - } else { - $this->assert($ret); - } + 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"); } /** - * Map an array element passed to seeInFormFields to its corresponding field, - * recursing through array values if the field is not found. + * Strips out one pair of trailing square brackets from a field's + * name. * - * @param array $fields The previously found fields. - * @param Crawler $form The form in which to search for fields. - * @param string $name The field's name. - * @param mixed $values - * @return void + * @param string $name the field name + * @return string the name after stripping trailing square brackets */ - protected function pushFormField(&$fields, $form, $name, $values) + protected function getSubmissionFormFieldName($name) { - $field = $form->filterXPath(sprintf('.//*[@name=%s]', Crawler::xpathLiteral($name))); + if (substr($name, -2) === '[]') { + return substr($name, 0, -2); + } + return $name; + } - if ($field->count()) { - $fields[] = [$field, $values]; - } elseif (is_array($values)) { - foreach ($values as $key => $value) { - $this->pushFormField($fields, $form, "{$name}[$key]", $value); + protected function isInternalDomain($domain) + { + if ($this->internalDomains === null) { + $this->internalDomains = $this->getInternalDomains(); + } + + foreach ($this->internalDomains as $pattern) { + if (preg_match($pattern, $domain)) { + return true; } - } else { - throw new ElementNotFound( - sprintf('//*[@name=%s]', Crawler::xpathLiteral($name)), - 'Form' - ); } + return false; + } + + /** + * @param $selector + * + * @return Crawler + * @throws ModuleException + */ + protected function match($selector) + { + 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 $name + * @param $form + * @param $dynamicField + * @return FormField + */ + protected function matchFormField($name, $form, $dynamicField) + { + if (substr($name, -2) !== '[]') { + return $form[$name]; + } + $name = substr($name, 0, -2); + /** @var $item FormField */ + foreach ($form[$name] as $item) { + if ($item == $dynamicField) { + return $item; + } + } + throw new TestRuntimeException("None of form fields by {$name}[] were not matched"); + } + + protected function matchOption(Crawler $field, $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()) { + $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; + } + + /** + * @param $select + * @return object|Crawler + * @throws ModuleException + */ + protected function matchSelectedOption($select) + { + $nodes = $this->getFieldsByLabelOrCss($select); + $selectedOptions = $nodes->filter('option[selected],input:checked'); + if ($selectedOptions->count() === 0) { + $selectedOptions = $nodes->filter('option,input')->first(); + } + return $selectedOptions; + } + + /** + * @param $option + * @return ChoiceFormField + * @throws ModuleException + */ + protected function proceedCheckOption($option) + { + $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; } protected function proceedSeeInField(Crawler $fields, $value) @@ -764,73 +880,152 @@ protected function proceedSeeInField(Crawler $fields, $value) } /** - * Get the values of a set of fields and also the texts of selected options. - * - * @param Crawler $nodes - * @return array|mixed|string + * @param $formSelector + * @param array $params + * @param $assertNot + * @throws ModuleException */ - protected function getValueAndTextFromField(Crawler $nodes) + protected function proceedSeeInFormFields($formSelector, array $params, $assertNot) { - if ($nodes->filter('textarea')->count()) { - return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue(); + $form = $this->match($formSelector)->first(); + if ($form->count() === 0) { + throw new ElementNotFound($formSelector, 'Form'); } - $input = $nodes->filter('input'); - if ($input->count()) { - return $this->getInputValue($input); + $fields = []; + foreach ($params as $name => $values) { + $this->pushFormField($fields, $form, $name, $values); } - if ($nodes->filter('select')->count()) { - $options = $nodes->filter('option[selected]'); - $values = []; - - foreach ($options as $option) { - $values[] = $option->getAttribute('value'); - $values[] = $option->textContent; - $values[] = trim($option->textContent); + foreach ($fields as list($field, $values)) { + if (!is_array($values)) { + $values = [$values]; } - return $values; + foreach ($values as $value) { + $ret = $this->proceedSeeInField($field, $value); + if ($assertNot) { + $this->assertNot($ret); + } else { + $this->assert($ret); + } + } } - - $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. + * Submits the form currently selected in the passed Crawler, after + * setting any values passed in $params and setting the value of the + * passed button name. * - * @param Crawler $input - * @return array|string + * @param Crawler $frmCrawl the form to submit + * @param array $params additional parameter values to set on the + * form + * @param string $button the name of a submit button in the form + * @throws ExternalUrlException|ModuleException */ - protected function getInputValue($input) + protected function proceedSubmitForm(Crawler $frmCrawl, array $params, $button = null) { - $inputType = $input->attr('type'); - if ($inputType === 'checkbox' || $inputType === 'radio') { - $values = []; + $url = null; + $form = $this->getFormFor($frmCrawl); + $defaults = $this->getFormValuesFor($form); + $merged = array_merge($defaults, $params); + $requestParams = $this->setCheckboxBoolValues($frmCrawl, $merged); - foreach ($input->filter(':checked') as $checkbox) { - $values[] = $checkbox->getAttribute('value'); + if (!empty($button)) { + $btnCrawl = $frmCrawl->filterXPath(sprintf( + '//*[not(@disabled) and @type="submit" and @name=%s]', + Crawler::xpathLiteral($button) + )); + if (count($btnCrawl)) { + $requestParams[$button] = $btnCrawl->attr('value'); + $formaction = $btnCrawl->attr('formaction'); + if ($formaction) { + $url = $formaction; + } } + } - return $values; + if (!$url) { + $url = $this->getFormUrl($frmCrawl); } - return (new InputFormField($input->getNode(0)))->getValue(); + 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 = []; } /** - * Strips out one pair of trailing square brackets from a field's - * name. + * Map an array element passed to seeInFormFields to its corresponding field, + * recursing through array values if the field is not found. * - * @param string $name the field name - * @return string the name after stripping trailing square brackets + * @param array $fields The previously found fields. + * @param Crawler $form The form in which to search for fields. + * @param string $name The field's name. + * @param mixed $values + * @return void */ - protected function getSubmissionFormFieldName($name) + protected function pushFormField(&$fields, $form, $name, $values) { - if (substr($name, -2) === '[]') { - return substr($name, 0, -2); - } - return $name; + $field = $form->filterXPath(sprintf('.//*[@name=%s]', Crawler::xpathLiteral($name))); + + if ($field->count()) { + $fields[] = [$field, $values]; + } elseif (is_array($values)) { + foreach ($values as $key => $value) { + $this->pushFormField($fields, $form, "{$name}[$key]", $value); + } + } else { + throw new ElementNotFound( + sprintf('//*[@name=%s]', Crawler::xpathLiteral($name)), + 'Form' + ); + } + } + + /** + * @param $result + * @param $maxRedirects + * @param $redirectCount + * @return mixed + * @throws ModuleException + */ + protected function redirectIfNecessary($result, $maxRedirects, $redirectCount) + { + $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; } /** @@ -882,809 +1077,261 @@ protected function setCheckboxBoolValues(Crawler $form, array $params) } /** - * Submits the form currently selected in the passed Crawler, after - * setting any values passed in $params and setting the value of the - * passed button name. - * - * @param Crawler $frmCrawl the form to submit - * @param array $params additional parameter values to set on the - * form - * @param string $button the name of a submit button in the form + * @param array $by + * @return Crawler + *@throws TestRuntimeException|ModuleException */ - protected function proceedSubmitForm(Crawler $frmCrawl, array $params, $button = null) + protected function strictMatch(array $by) { - $url = null; - $form = $this->getFormFor($frmCrawl); - $defaults = $this->getFormValuesFor($form); - $merged = array_merge($defaults, $params); - $requestParams = $this->setCheckboxBoolValues($frmCrawl, $merged); + $type = key($by); + $locator = $by[$type]; + switch ($type) { + case 'id': + return $this->filterByCSS("#$locator"); + case 'name': + return $this->filterByXPath(sprintf('.//*[@name=%s]', Crawler::xpathLiteral($locator))); + case 'css': + return $this->filterByCSS($locator); + case 'xpath': + return $this->filterByXPath($locator); + case 'link': + return $this->filterByXPath(sprintf('.//a[.=%s or contains(./@title, %s)]', Crawler::xpathLiteral($locator), Crawler::xpathLiteral($locator))); + case 'class': + return $this->filterByCSS(".$locator"); + default: + throw new TestRuntimeException( + "Locator type '$by' is not defined. Use either: xpath, css, id, link, class, name" + ); + } + } - if (!empty($button)) { - $btnCrawl = $frmCrawl->filterXPath(sprintf( - '//*[not(@disabled) and @type="submit" and @name=%s]', - Crawler::xpathLiteral($button) - )); - if (count($btnCrawl)) { - $requestParams[$button] = $btnCrawl->attr('value'); - $formaction = $btnCrawl->attr('formaction'); - if ($formaction) { - $url = $formaction; + 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'] + )); } } + } - if (!$url) { - $url = $this->getFormUrl($frmCrawl); - } + public function _after(TestInterface $test) + { + $this->client = null; + $this->crawler = null; + $this->forms = []; + $this->headers = []; + } - if (strcasecmp($form->getMethod(), 'GET') === 0) { - $url = Uri::mergeUrls($url, '?' . http_build_query($requestParams)); + public function _failed(TestInterface $test, $fail) + { + try { + if (!$this->client || !$this->client->getInternalResponse()) { + return; + } + } catch (BadMethodCallException $e) { + //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)); - $url = preg_replace('/#.*/', '', $url); + $extensions = [ + 'application/json' => 'json', + 'text/xml' => 'xml', + 'application/xml' => 'xml', + 'text/plain' => 'txt' + ]; - $this->debugSection('Uri', $url); - $this->debugSection('Method', $form->getMethod()); - $this->debugSection('Parameters', $requestParams); + try { + $internalResponse = $this->client->getInternalResponse(); + } catch (BadMethodCallException $e) { + $internalResponse = false; + } - $requestParams= $this->getFormPhpValues($requestParams); + $responseContentType = $internalResponse ? $internalResponse->getHeader('content-type') : ''; + list($responseMimeType) = explode(';', $responseContentType); - $this->crawler = $this->clientRequest( - $form->getMethod(), - $url, - $requestParams, - $form->getPhpFiles() - ); - $this->forms = []; - } + $extension = isset($extensions[$responseMimeType]) ? $extensions[$responseMimeType] : 'html'; - public function submitForm($selector, array $params, $button = null) - { - $form = $this->match($selector)->first(); - if (!count($form)) { - throw new ElementNotFound($this->stringifySelector($selector), 'Form'); - } - $this->proceedSubmitForm($form, $params, $button); + $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); } /** - * 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 \Codeception\Exception\TestRuntimeException if either the current - * URL or the passed URI can't be parsed + * @return string */ - protected function getAbsoluteUrlFor($uri) + public function _getCurrentUri() { - $currentUrl = $this->getRunningClient()->getHistory()->current()->getUri(); - if (empty($uri) || strpos($uri, '#') === 0) { - return $currentUrl; - } - return Uri::mergeUrls($currentUrl, $uri); + return Uri::retrieveUri($this->getRunningClient()->getHistory()->current()->getUri()); } /** - * Returns the form action's absolute URL. + * Returns content of the last response + * Use it in Helpers when you want to retrieve response of request performed by another module. + * + * ```php + * assertStringContainsString($text, $this->getModule('{{MODULE_NAME}}')->_getResponseContent(), "response contains"); + * } + * ?> + * ``` * - * @param \Symfony\Component\DomCrawler\Crawler $form + * @api * @return string - * @throws \Codeception\Exception\TestRuntimeException if either the current - * URL or the URI of the form's action can't be parsed */ - protected function getFormUrl(Crawler $form) + public function _getResponseContent() { - $action = $form->form()->getUri(); - return $this->getAbsoluteUrlFor($action); + return (string)$this->getRunningClient()->getInternalResponse()->getContent(); } /** - * Returns a crawler Form object for the form pointed to by the - * passed Crawler. - * - * 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 Crawler $form the form - * @return Form + * @return int|string */ - private function getFormFromCrawler(Crawler $form) + public function _getResponseStatusCode() { - $fakeDom = new \DOMDocument(); - $fakeDom->appendChild($fakeDom->importNode($form->getNode(0), true)); - $node = $fakeDom->documentElement; - $action = (string)$this->getFormUrl($form); - $cloned = new Crawler($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(); + return $this->getResponseStatusCode(); } /** - * Returns the DomCrawler\Form object for the form pointed to by - * $node or its closes form parent. + * Opens a page with arbitrary request parameters. + * Useful for testing multi-step forms on a specific step. * - * @param \Symfony\Component\DomCrawler\Crawler $node - * @return \Symfony\Component\DomCrawler\Form - */ - protected function getFormFor(Crawler $node) - { - if (strcasecmp($node->first()->getNode(0)->tagName, 'form') === 0) { - $form = $node->first(); - } else { - $form = $node->parents()->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 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. + * ```php + * getModule('{{MODULE_NAME}}')->_loadPage('POST', '/checkout/step2', ['order' => $orderId]); + * } + * ?> + * ``` * - * @param \Symfony\Component\DomCrawler\Form the form - * @return array an array of name => value pairs + * @api + * @param $method + * @param $uri + * @param array $parameters + * @param array $files + * @param array $server + * @param null $content */ - protected function getFormValuesFor(Form $form) - { - $values = []; - $fields = $form->all(); - foreach ($fields as $field) { - if ($field instanceof FileFormField || $field->isDisabled() || !$field->hasValue()) { - continue; - } - $fieldName = $this->getSubmissionFormFieldName($field->getName()); - if (substr($field->getName(), -2) === '[]') { - if (!isset($values[$fieldName])) { - $values[$fieldName] = []; - } - $values[$fieldName][] = $field->getValue(); - } else { - $values[$fieldName] = $field->getValue(); - } - } - return $values; - } - - public function fillField($field, $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)); - if ($input->getNode(0)->tagName === 'textarea') { - $input->getNode(0)->nodeValue = htmlspecialchars($value); - } + public function _loadPage( + $method, + $uri, + array $parameters = [], + array $files = [], + array $server = [], + $content = null + ) { + $this->crawler = $this->clientRequest($method, $uri, $parameters, $files, $server, $content); + $this->baseUrl = $this->retrieveBaseUrl(); + $this->forms = []; } /** - * @param $field + * 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. * - * @return \Symfony\Component\DomCrawler\Crawler - */ - protected function getFieldsByLabelOrCss($field) - { - if (is_array($field)) { - $input = $this->strictMatch($field); - if (!count($input)) { - throw new ElementNotFound($field); - } - return $input; - } - - // by label - $label = $this->strictMatch(['xpath' => sprintf('.//label[descendant-or-self::node()[text()[normalize-space()=%s]]]', Crawler::xpathLiteral($field))]); - if (count($label)) { - $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', Crawler::xpathLiteral($field))]); - } - } - - // by name - if (!isset($input)) { - $input = $this->strictMatch(['name' => $field]); - } - - // by CSS and XPath - if (!count($input)) { - $input = $this->match($field); - } - - if (!count($input)) { - throw new ElementNotFound($field, 'Form field by Label or CSS'); - } - - return $input; - } - - protected function getFieldByLabelOrCss($field) - { - $input = $this->getFieldsByLabelOrCss($field); - return $input->first(); - } - - public function selectOption($select, $option) - { - $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($this->matchOption($field, $option)); - } - - protected function matchOption(Crawler $field, $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()) { - $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) - { - $this->proceedCheckOption($option)->tick(); - } - - public function uncheckOption($option) - { - $this->proceedCheckOption($option)->untick(); - } - - /** - * @param $option - * @return ChoiceFormField + * ```php + * 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. + * + * @api + * @param $method + * @param $uri + * @param array $parameters + * @param array $files + * @param array $server + * @param null $content + * @return mixed|Crawler + * @see `_loadPage` */ - protected function proceedCheckOption($option) - { - $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, $filename) - { - $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); + public function _request( + $method, + $uri, + array $parameters = [], + array $files = [], + array $server = [], + $content = null + ) { + $this->clientRequest($method, $uri, $parameters, $files, $server, $content, true); + return $this->_getResponseContent(); } /** - * Sends an ajax GET request with the passed parameters. - * See `sendAjaxPostRequest()` + * Authenticates user for HTTP_AUTH * - * @param $uri - * @param $params + * @param $username + * @param $password */ - public function sendAjaxGetRequest($uri, $params = []) + public function amHttpAuthenticated($username, $password) { - $this->sendAjaxRequest('GET', $uri, $params); + $this->client->setServerParameter('PHP_AUTH_USER', $username); + $this->client->setServerParameter('PHP_AUTH_PW', $password); } /** - * Sends an ajax POST request with the passed parameters. - * The appropriate HTTP header is added automatically: - * `X-Requested-With: XMLHttpRequest` + * Deletes the header with the passed name. Subsequent requests + * will not have the deleted header in its request. + * * Example: - * ``` php + * ```php * sendAjaxPostRequest('/add-task', ['task' => 'lorem ipsum']); + * $I->haveHttpHeader('X-Requested-With', 'Codeception'); + * $I->amOnPage('test-headers.php'); + * // ... + * $I->deleteHeader('X-Requested-With'); + * $I->amOnPage('some-other-page.php'); + * ?> * ``` - * Some frameworks (e.g. Symfony) create field names in the form of an "array": - * `` - * In this case you need to pass the fields like this: - * ``` php - * sendAjaxPostRequest('/add-task', ['form' => [ - * 'task' => 'lorem ipsum', - * 'category' => 'miscellaneous', - * ]]); - * ``` * - * @param string $uri - * @param array $params + * @param string $name the name of the header to delete. */ - public function sendAjaxPostRequest($uri, $params = []) + public function deleteHeader($name) { - $this->sendAjaxRequest('POST', $uri, $params); - } - - /** - * Sends an ajax request, using the passed HTTP method. - * See `sendAjaxPostRequest()` - * Example: - * ``` php - * sendAjaxRequest('PUT', '/posts/7', ['title' => 'new title']); - * ``` - * - * @param $method - * @param $uri - * @param $params - */ - public function sendAjaxRequest($method, $uri, $params = []) - { - $this->clientRequest($method, $uri, $params, [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], null, false); - } - - /** - * @param $url - */ - protected function debugResponse($url) - { - $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($name = null) - { - if (empty($name)) { - $name = uniqid(date("Y-m-d_H-i-s_"), true); - } - $debugDir = codecept_output_dir() . 'debug'; - if (!is_dir($debugDir)) { - mkdir($debugDir, 0777); - } - $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 $selector - * - * @return Crawler - */ - protected function match($selector) - { - 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 array $by - * @throws TestRuntimeException - * @return Crawler - */ - protected function strictMatch(array $by) - { - $type = key($by); - $locator = $by[$type]; - switch ($type) { - case 'id': - return $this->filterByCSS("#$locator"); - case 'name': - return $this->filterByXPath(sprintf('.//*[@name=%s]', Crawler::xpathLiteral($locator))); - case 'css': - return $this->filterByCSS($locator); - case 'xpath': - return $this->filterByXPath($locator); - case 'link': - return $this->filterByXPath(sprintf('.//a[.=%s or contains(./@title, %s)]', Crawler::xpathLiteral($locator), Crawler::xpathLiteral($locator))); - case 'class': - return $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 function (Crawler $node) use ($attr, $val) { - return $node->attr($attr) === $val; - } - ); - } - return $nodes; - } - - public function grabTextFrom($cssOrXPathOrRegex) - { - if (@preg_match($cssOrXPathOrRegex, $this->client->getInternalResponse()->getContent(), $matches)) { - return $matches[1]; - } - $nodes = $this->match($cssOrXPathOrRegex); - if ($nodes->count()) { - return $nodes->first()->text(); - } - throw new ElementNotFound($cssOrXPathOrRegex, 'Element that matches CSS or XPath or Regex'); - } - - public function grabAttributeFrom($cssOrXpath, $attribute) - { - $nodes = $this->match($cssOrXpath); - if (!$nodes->count()) { - throw new ElementNotFound($cssOrXpath, 'Element that matches CSS or XPath'); - } - return $nodes->first()->attr($attribute); - } - - public function grabMultiple($cssOrXpath, $attribute = null) - { - $result = []; - $nodes = $this->match($cssOrXpath); - - foreach ($nodes as $node) { - if ($attribute !== null) { - $result[] = $node->getAttribute($attribute); - } else { - $result[] = $node->textContent; - } - } - return $result; - } - - /** - * @param $field - * - * @return array|mixed|null|string - */ - public function grabValueFrom($field) - { - $nodes = $this->match($field); - if (!$nodes->count()) { - throw new ElementNotFound($field, 'Field'); - } - - if ($nodes->filter('textarea')->count()) { - return (new TextareaFormField($nodes->filter('textarea')->getNode(0)))->getValue(); - } - - $input = $nodes->filter('input'); - if ($input->count()) { - return $this->getInputValue($input); - } - - if ($nodes->filter('select')->count()) { - $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, array $params = []) - { - $cookies = $this->client->getCookieJar(); - $params = array_merge($this->defaultCookieParameters, $params); - - $expires = isset($params['expiry']) ? $params['expiry'] : null; // WebDriver compatibility - $expires = isset($params['expires']) && !$expires ? $params['expires'] : null; - $path = isset($params['path']) ? $params['path'] : null; - $domain = isset($params['domain']) ? $params['domain'] : ''; - $secure = isset($params['secure']) ? $params['secure'] : false; - $httpOnly = isset($params['httpOnly']) ? $params['httpOnly'] : true; - $encodedValue = isset($params['encodedValue']) ? $params['encodedValue'] : false; - - - - $cookies->set(new Cookie($name, $val, $expires, $path, $domain, $secure, $httpOnly, $encodedValue)); - $this->debugCookieJar(); - } - - public function grabCookie($cookie, array $params = []) - { - $params = array_merge($this->defaultCookieParameters, $params); - $this->debugCookieJar(); - $cookies = $this->getRunningClient()->getCookieJar()->get($cookie, $params['path'], $params['domain']); - if (!$cookies) { - return null; - } - return $cookies->getValue(); - } - - /** - * Grabs current page source code. - * - * @throws ModuleException if no page was opened. - * - * @return string Current page source code. - */ - public function grabPageSource() - { - return $this->_getResponseContent(); - } - - public function seeCookie($cookie, array $params = []) - { - $params = array_merge($this->defaultCookieParameters, $params); - $this->debugCookieJar(); - $this->assertNotNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain'])); - } - - public function dontSeeCookie($cookie, array $params = []) - { - $params = array_merge($this->defaultCookieParameters, $params); - $this->debugCookieJar(); - $this->assertNull($this->client->getCookieJar()->get($cookie, $params['path'], $params['domain'])); - } - - public function resetCookie($name, array $params = []) - { - $params = array_merge($this->defaultCookieParameters, $params); - $this->client->getCookieJar()->expire($name, $params['path'], $params['domain']); - $this->debugCookieJar(); - } - - private function stringifySelector($selector) - { - if (is_array($selector)) { - return trim(json_encode($selector), '{}'); - } - return $selector; - } - - public function seeElement($selector, $attributes = []) - { - $nodes = $this->match($selector); - $selector = $this->stringifySelector($selector); - if (!empty($attributes)) { - $nodes = $this->filterByAttributes($nodes, $attributes); - $selector .= "' with attribute(s) '" . trim(json_encode($attributes), '{}'); - } - $this->assertDomContains($nodes, $selector); - } - - public function dontSeeElement($selector, $attributes = []) - { - $nodes = $this->match($selector); - $selector = $this->stringifySelector($selector); - if (!empty($attributes)) { - $nodes = $this->filterByAttributes($nodes, $attributes); - $selector .= "' with attribute(s) '" . trim(json_encode($attributes), '{}'); - } - $this->assertDomNotContains($nodes, $selector); - } - - public function seeNumberOfElements($selector, $expected) - { - $counted = count($this->match($selector)); - if (is_array($expected)) { - list($floor, $ceil) = $expected; - $this->assertTrue( - $floor <= $counted && $ceil >= $counted, - 'Number of elements counted differs from expected range' - ); - } else { - $this->assertEquals( - $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->assertEquals($optionText, $value); - } - - public function dontSeeOptionIsSelected($selector, $optionText) - { - $selected = $this->matchSelectedOption($selector); - if (!$selected->count()) { - $this->assertEquals(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->assertNotEquals($optionText, $value); - } - - protected function matchSelectedOption($select) - { - $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() - { - $this->seeResponseCodeIs(404); - } - - /** - * Checks that response code is equal to value provided. - * - * ```php - * seeResponseCodeIs(200); - * - * // recommended \Codeception\Util\HttpCode - * $I->seeResponseCodeIs(\Codeception\Util\HttpCode::OK); - * ``` - * - * @param $code - */ - public function seeResponseCodeIs($code) - { - $failureMessage = sprintf( - 'Expected HTTP Status Code: %s. Actual Status Code: %s', - HttpCode::getDescription($code), - HttpCode::getDescription($this->getResponseStatusCode()) - ); - $this->assertEquals($code, $this->getResponseStatusCode(), $failureMessage); - } - - /** - * Checks that response code is between a certain range. Between actually means [from <= CODE <= to] - * - * @param $from - * @param $to - */ - public function seeResponseCodeIsBetween($from, $to) - { - $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); + $name = implode('-', array_map('ucfirst', explode('-', strtolower(str_replace('_', '-', $name))))); + unset($this->headers[$name]); } /** @@ -1693,250 +1340,67 @@ public function seeResponseCodeIsBetween($from, $to) * ```php * dontSeeResponseCodeIs(200); - * - * // recommended \Codeception\Util\HttpCode - * $I->dontSeeResponseCodeIs(\Codeception\Util\HttpCode::OK); - * ``` - * @param $code - */ - public function dontSeeResponseCodeIs($code) - { - $failureMessage = sprintf( - 'Expected HTTP status code other than %s', - HttpCode::getDescription($code) - ); - $this->assertNotEquals($code, $this->getResponseStatusCode(), $failureMessage); - } - - /** - * Checks that the response code 2xx - */ - public function seeResponseCodeIsSuccessful() - { - $this->seeResponseCodeIsBetween(200, 299); - } - - /** - * Checks that the response code 3xx - */ - public function seeResponseCodeIsRedirection() - { - $this->seeResponseCodeIsBetween(300, 399); - } - - /** - * Checks that the response code is 4xx - */ - public function seeResponseCodeIsClientError() - { - $this->seeResponseCodeIsBetween(400, 499); - } - - /** - * Checks that the response code is 5xx - */ - public function seeResponseCodeIsServerError() - { - $this->seeResponseCodeIsBetween(500, 599); - } - - public function seeInTitle($title) - { - $nodes = $this->getCrawler()->filter('title'); - if (!$nodes->count()) { - throw new ElementNotFound("