diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3729589..b5e8e6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,15 @@ on: jobs: tests: uses: zenstruck/.github/.github/workflows/php-test-symfony.yml@main - with: - phpunit: simple-phpunit code-coverage: uses: zenstruck/.github/.github/workflows/php-coverage-codecov.yml@main - with: - phpunit: simple-phpunit composer-validate: uses: zenstruck/.github/.github/workflows/php-composer-validate.yml@main cs-check: uses: zenstruck/.github/.github/workflows/php-cs-fixer.yml@main + + sca: + uses: zenstruck/.github/.github/workflows/php-stan.yml@main diff --git a/composer.json b/composer.json index 9f64a8f..87b6845 100644 --- a/composer.json +++ b/composer.json @@ -24,9 +24,11 @@ }, "require-dev": { "dbrekelmans/bdi": "^0.3.0", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5", "symfony/mime": ">=4.4.1", "symfony/panther": "^1.0|^2.0", - "symfony/phpunit-bridge": "^5.3", + "symfony/phpunit-bridge": "^6.0", "symfony/security-bundle": "^4.4|^5.0|^6.0" }, "config": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..401d12e --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,217 @@ +parameters: + ignoreErrors: + - + message: "#^Method Zenstruck\\\\Browser\\:\\:assertNotOn\\(\\) has parameter \\$parts with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser.php + + - + message: "#^Method Zenstruck\\\\Browser\\:\\:assertOn\\(\\) has parameter \\$parts with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser.php + + - + message: "#^Method Zenstruck\\\\Browser\\:\\:selectField\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser.php + + - + message: "#^Method Zenstruck\\\\Browser\\:\\:selectFieldOptions\\(\\) has parameter \\$values with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser.php + + - + message: "#^Parameter \\#2 \\$path of method Behat\\\\Mink\\\\Element\\\\TraversableElement\\:\\:attachFileToField\\(\\) expects string, array\\\\|string given\\.$#" + count: 1 + path: src/Browser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Assertion\\\\SameUrlAssertion\\:\\:__construct\\(\\) has parameter \\$partsToMatch with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Assertion\\\\SameUrlAssertion\\:\\:context\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Assertion\\\\SameUrlAssertion\\:\\:parseUrl\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Assertion\\\\SameUrlAssertion\\:\\:parseUrl\\(\\) should return array but returns array\\\\|false\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Parameter \\#1 \\$array of function array_keys expects array, array\\\\|false given\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Property Zenstruck\\\\Browser\\\\Assertion\\\\SameUrlAssertion\\:\\:\\$partsToMatch type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Assertion/SameUrlAssertion.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:delete\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:get\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:post\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:put\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:request\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\BrowserKitBrowser\\:\\:setDefaultHttpOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/BrowserKitBrowser.php + + - + message: "#^Parameter \\#1 \\$token of method Symfony\\\\Component\\\\HttpKernel\\\\Profiler\\\\Profiler\\:\\:loadProfile\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: src/Browser/HttpBrowser.php + + - + message: "#^Cannot access offset 'query' on array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false\\.$#" + count: 2 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:__construct\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:create\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:files\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:merge\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:parameters\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:server\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:withBody\\(\\) has parameter \\$body with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:withFiles\\(\\) has parameter \\$files with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:withHeaders\\(\\) has parameter \\$headers with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:withQuery\\(\\) has parameter \\$query with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\HttpOptions\\:\\:withServer\\(\\) has parameter \\$server with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Parameter \\#1 \\$options of class Zenstruck\\\\Browser\\\\HttpOptions constructor expects array, array\\|Zenstruck\\\\Browser\\\\HttpOptions given\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, int\\|string given\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Property Zenstruck\\\\Browser\\\\HttpOptions\\:\\:\\$options type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/HttpOptions.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\BrowserKitDriver\\:\\:getFormField\\(\\) should return Symfony\\\\Component\\\\DomCrawler\\\\Field\\\\FormField but returns array\\\\|Symfony\\\\Component\\\\DomCrawler\\\\Field\\\\FormField\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\BrowserKitDriver\\:\\:getResponseHeaders\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\BrowserKitDriver\\:\\:getValue\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\BrowserKitDriver\\:\\:setValue\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Parameter \\#1 \\$path of function dirname expects string, string\\|false\\|null given\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Parameter \\#1 \\$value of method Symfony\\\\Component\\\\DomCrawler\\\\Field\\\\FormField\\:\\:setValue\\(\\) expects string\\|null, array\\|bool\\|string given\\.$#" + count: 1 + path: src/Browser/Mink/BrowserKitDriver.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\PantherDriver\\:\\:getValue\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Mink/PantherDriver.php + + - + message: "#^Method Zenstruck\\\\Browser\\\\Mink\\\\PantherDriver\\:\\:setValue\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Browser/Mink/PantherDriver.php + + - + message: "#^Parameter \\#1 \\$value of method Symfony\\\\Component\\\\DomCrawler\\\\Field\\\\FormField\\:\\:setValue\\(\\) expects string\\|null, array\\|bool\\|string given\\.$#" + count: 1 + path: src/Browser/Mink/PantherDriver.php + + - + message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, array\\|bool\\|string given\\.$#" + count: 1 + path: src/Browser/Mink/PantherDriver.php + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..28cf30b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 8 + paths: + - src diff --git a/src/Browser.php b/src/Browser.php index 9703927..3e9ba84 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -26,6 +26,8 @@ class Browser private Mink $mink; private ?string $sourceDir = null; + + /** @var string[] */ private array $savedSources = []; /** @@ -175,8 +177,8 @@ final public function checkField(string $selector): self { $field = $this->documentElement()->findField($selector); - if ($field && 'radio' === \mb_strtolower($field->getAttribute('type'))) { - $this->documentElement()->selectFieldOption($selector, $field->getAttribute('value')); + if ($field && 'radio' === \mb_strtolower((string) $field->getAttribute('type'))) { + $this->documentElement()->selectFieldOption($selector, (string) $field->getAttribute('value')); return $this; } @@ -273,7 +275,11 @@ final public function click(string $selector): self try { $this->documentElement()->clickLink($selector); } catch (ElementNotFoundException $e) { - $this->documentElement()->find('css', $selector)->click(); + if (!$element = $this->documentElement()->find('css', $selector)) { + throw $e; + } + + $element->click(); } } @@ -452,6 +458,8 @@ public function dumpCurrentState(string $filename): void /** * @internal + * + * @return array */ public function savedArtifacts(): array { diff --git a/src/Browser/BrowserKitBrowser.php b/src/Browser/BrowserKitBrowser.php index 2921ae1..53cfd37 100644 --- a/src/Browser/BrowserKitBrowser.php +++ b/src/Browser/BrowserKitBrowser.php @@ -10,12 +10,18 @@ /** * @author Kevin Bond + * + * @template B of AbstractBrowser */ abstract class BrowserKitBrowser extends Browser { + /** @var B */ private AbstractBrowser $inner; private ?HttpOptions $defaultHttpOptions = null; + /** + * @param B $inner + */ public function __construct(AbstractBrowser $inner) { $this->inner = $inner; @@ -119,6 +125,8 @@ final public function request(string $method, string $url, $options = []): self /** * @see request() * + * @param HttpOptions|array $options + * * @return static */ final public function get(string $url, $options = []): self @@ -129,6 +137,8 @@ final public function get(string $url, $options = []): self /** * @see request() * + * @param HttpOptions|array $options + * * @return static */ final public function post(string $url, $options = []): self @@ -139,6 +149,8 @@ final public function post(string $url, $options = []): self /** * @see request() * + * @param HttpOptions|array $options + * * @return static */ final public function put(string $url, $options = []): self @@ -149,6 +161,8 @@ final public function put(string $url, $options = []): self /** * @see request() * + * @param HttpOptions|array $options + * * @return static */ final public function delete(string $url, $options = []): self @@ -237,6 +251,9 @@ final public function assertJsonMatches(string $expression, $expected): self abstract public function profile(): Profile; + /** + * @return B + */ final protected function inner(): AbstractBrowser { return $this->inner; diff --git a/src/Browser/HttpBrowser.php b/src/Browser/HttpBrowser.php index 775841e..1e8e47c 100644 --- a/src/Browser/HttpBrowser.php +++ b/src/Browser/HttpBrowser.php @@ -8,6 +8,8 @@ /** * @author Kevin Bond + * + * @extends BrowserKitBrowser */ class HttpBrowser extends BrowserKitBrowser { diff --git a/src/Browser/HttpOptions.php b/src/Browser/HttpOptions.php index 50aac09..535cef8 100644 --- a/src/Browser/HttpOptions.php +++ b/src/Browser/HttpOptions.php @@ -54,6 +54,8 @@ final public static function create($options = []): self } /** + * @param mixed $body + * * @return static */ final public static function json($body = null): self @@ -70,6 +72,8 @@ final public static function ajax(): self } /** + * @param mixed $body + * * @return static */ final public static function jsonAjax($body = null): self diff --git a/src/Browser/KernelBrowser.php b/src/Browser/KernelBrowser.php index fdd43e0..61f8674 100644 --- a/src/Browser/KernelBrowser.php +++ b/src/Browser/KernelBrowser.php @@ -8,6 +8,8 @@ /** * @author Kevin Bond + * + * @extends BrowserKitBrowser */ class KernelBrowser extends BrowserKitBrowser { diff --git a/src/Browser/Mink/BrowserKitDriver.php b/src/Browser/Mink/BrowserKitDriver.php index 201054c..d0a4e7c 100644 --- a/src/Browser/Mink/BrowserKitDriver.php +++ b/src/Browser/Mink/BrowserKitDriver.php @@ -24,7 +24,6 @@ use Symfony\Component\DomCrawler\Field\InputFormField; use Symfony\Component\DomCrawler\Field\TextareaFormField; use Symfony\Component\DomCrawler\Form; -use Symfony\Component\HttpKernel\HttpKernelBrowser; /** * Copied from https://github.com/minkphp/MinkBrowserKitDriver for use @@ -39,73 +38,19 @@ */ final class BrowserKitDriver extends CoreDriver { - private $client; + private AbstractBrowser $client; - /** - * @var Form[] - */ - private $forms = []; - private $serverParameters = []; - private $started = false; - private $removeScriptFromUrl = false; - private $removeHostFromUrl = false; + /** @var Form[] */ + private array $forms = []; - /** - * Initializes BrowserKit driver. - * - * @param AbstractBrowser $client BrowserKit client instance - * @param string|null $baseUrl Base URL for HttpKernel clients - */ - public function __construct(AbstractBrowser $client, $baseUrl = null) + /** @var array */ + private array $serverParameters = []; + private bool $started = false; + + public function __construct(AbstractBrowser $client) { $this->client = $client; $this->client->followRedirects(true); - - if (null !== $baseUrl && $client instanceof HttpKernelBrowser) { - $client->setServerParameter('SCRIPT_FILENAME', \parse_url($baseUrl, \PHP_URL_PATH)); - } - } - - /** - * Returns BrowserKit HTTP client instance. - * - * @return AbstractBrowser - */ - public function getClient() - { - return $this->client; - } - - /** - * Tells driver to remove hostname from URL. - * - * @param bool $remove - * - * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead. - */ - public function setRemoveHostFromUrl($remove = true) - { - @\trigger_error( - 'setRemoveHostFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.', - \E_USER_DEPRECATED - ); - $this->removeHostFromUrl = (bool) $remove; - } - - /** - * Tells driver to remove script name from URL. - * - * @param bool $remove - * - * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead. - */ - public function setRemoveScriptFromUrl($remove = true) - { - @\trigger_error( - 'setRemoveScriptFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.', - \E_USER_DEPRECATED - ); - $this->removeScriptFromUrl = (bool) $remove; } public function start(): void @@ -134,7 +79,7 @@ public function reset(): void public function visit($url): void { - $this->client->request('GET', $this->prepareUrl($url), [], [], $this->serverParameters); + $this->client->request('GET', $url, [], [], $this->serverParameters); $this->forms = []; } @@ -173,6 +118,9 @@ public function back(): void $this->forms = []; } + /** + * @param false|string $user + */ public function setBasicAuth($user, $password): void { if (false === $user) { @@ -239,14 +187,7 @@ public function getCookie($name): ?string public function getStatusCode(): int { - $response = $this->getResponse(); - - // BC layer for Symfony < 4.3 - if (!\method_exists($response, 'getStatusCode')) { - return $response->getStatus(); - } - - return $response->getStatusCode(); + return $this->getResponse()->getStatusCode(); } public function getContent(): string @@ -254,18 +195,6 @@ public function getContent(): string return $this->getResponse()->getContent(); } - public function findElementXpaths($xpath): array - { - $nodes = $this->getCrawler()->filterXPath($xpath); - - $elements = []; - foreach ($nodes as $i => $node) { - $elements[] = \sprintf('(%s)[%d]', $xpath, $i + 1); - } - - return $elements; - } - public function getTagName($xpath): string { return $this->getCrawlerNode($this->getFilteredCrawler($xpath))->nodeName; @@ -273,12 +202,7 @@ public function getTagName($xpath): string public function getText($xpath): string { - $text = $this->getFilteredCrawler($xpath)->text(null, true); - // TODO drop our own normalization once supporting only dom-crawler 4.4+ as it already does it. - $text = \str_replace("\n", ' ', $text); - $text = \preg_replace('/ {2,}/', ' ', $text); - - return \trim($text); + return \trim($this->getFilteredCrawler($xpath)->text(null, true)); } public function getHtml($xpath): string @@ -288,15 +212,7 @@ public function getHtml($xpath): string public function getOuterHtml($xpath): string { - $crawler = $this->getFilteredCrawler($xpath); - - if (\method_exists($crawler, 'outerHtml')) { - return $crawler->outerHtml(); - } - - $node = $this->getCrawlerNode($crawler); - - return $node->ownerDocument->saveHTML($node); + return $this->getFilteredCrawler($xpath)->outerHtml(); } public function getAttribute($xpath, $name): ?string @@ -310,9 +226,6 @@ public function getAttribute($xpath, $name): ?string return null; } - /** - * @return mixed - */ public function getValue($xpath) { if (\in_array($this->getAttribute($xpath, 'type'), ['submit', 'image', 'button'], true)) { @@ -420,6 +333,9 @@ public function isChecked($xpath): bool return $radio->getAttribute('value') === $field->getValue(); } + /** + * @param string|string[] $path + */ public function attachFile($xpath, $path): void { $files = (array) $path; @@ -431,7 +347,7 @@ public function attachFile($xpath, $path): void $field->upload(\array_shift($files)); - if (empty($files)) { + if (!$files) { // not multiple files return; } @@ -460,12 +376,19 @@ public function submitForm($xpath): void $this->submit($crawler->form()); } - /** - * @return Response - * - * @throws DriverException If there is not response yet - */ - protected function getResponse() + protected function findElementXpaths($xpath): array + { + $nodes = $this->getCrawler()->filterXPath($xpath); + + $elements = []; + foreach ($nodes as $i => $node) { + $elements[] = \sprintf('(%s)[%d]', $xpath, $i + 1); + } + + return $elements; + } + + private function getResponse(): Response { try { $response = $this->client->getInternalResponse(); @@ -481,35 +404,7 @@ protected function getResponse() return $response; } - /** - * Prepares URL for visiting. - * Removes "*.php/" from urls and then passes it to BrowserKitDriver::visit(). - * - * @param string $url - * - * @return string - */ - protected function prepareUrl($url) - { - if (!$this->removeHostFromUrl && !$this->removeScriptFromUrl) { - return $url; - } - - $replacement = ($this->removeHostFromUrl ? '' : '$1').($this->removeScriptFromUrl ? '' : '$2'); - - return \preg_replace('#(https?\://[^/]+)(/[^/\.]+\.php)?#', $replacement, $url); - } - - /** - * Returns form field from XPath query. - * - * @param string $xpath - * - * @return FormField - * - * @throws DriverException - */ - protected function getFormField($xpath) + private function getFormField(string $xpath): FormField { $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath)); $fieldName = \str_replace('[]', '', $fieldNode->getAttribute('name')); @@ -537,10 +432,8 @@ private function getFormForFieldNode(\DOMElement $fieldNode): Form /** * Deletes a cookie by name. - * - * @param string $name cookie name */ - private function deleteCookie($name) + private function deleteCookie(string $name): void { $path = $this->getCookiePath(); $jar = $this->client->getCookieJar(); @@ -556,10 +449,8 @@ private function deleteCookie($name) /** * Returns current cookie path. - * - * @return string */ - private function getCookiePath() + private function getCookiePath(): string { $path = \dirname(\parse_url($this->getCurrentUrl(), \PHP_URL_PATH)); @@ -573,13 +464,9 @@ private function getCookiePath() /** * Returns the checkbox field from xpath query, ensuring it is valid. * - * @param string $xpath - * - * @return ChoiceFormField - * * @throws DriverException when the field is not a checkbox */ - private function getCheckboxField($xpath) + private function getCheckboxField(string $xpath): ChoiceFormField { $field = $this->getFormField($xpath); @@ -591,14 +478,17 @@ private function getCheckboxField($xpath) } /** - * @return \DOMElement - * * @throws DriverException if the form node cannot be found */ - private function getFormNode(\DOMElement $element) + private function getFormNode(\DOMElement $element): \DOMElement { if ($element->hasAttribute('form')) { $formId = $element->getAttribute('form'); + + if (!$element->ownerDocument) { + throw new DriverException(\sprintf('The selected node has an invalid form attribute (%s).', $formId)); + } + $formNode = $element->ownerDocument->getElementById($formId); if (null === $formNode || 'form' !== $formNode->nodeName) { @@ -626,10 +516,8 @@ private function getFormNode(\DOMElement $element) * BrowserKit uses the field name as index to find the field in its Form object. * When multiple fields have the same name (checkboxes for instance), it will return * an array of elements in the order they appear in the DOM. - * - * @return int */ - private function getFieldPosition(\DOMElement $fieldNode) + private function getFieldPosition(\DOMElement $fieldNode): int { $elements = $this->getCrawler()->filterXPath('//*[@name=\''.$fieldNode->getAttribute('name').'\']'); @@ -647,7 +535,7 @@ private function getFieldPosition(\DOMElement $fieldNode) return 0; } - private function submit(Form $form) + private function submit(Form $form): void { $formId = $this->getFormNodeId($form->getFormNode()); @@ -674,7 +562,7 @@ private function submit(Form $form) $this->forms = []; } - private function resetForm(\DOMElement $fieldNode) + private function resetForm(\DOMElement $fieldNode): void { $formNode = $this->getFormNode($fieldNode); $formId = $this->getFormNodeId($formNode); @@ -683,12 +571,8 @@ private function resetForm(\DOMElement $fieldNode) /** * Determines if a node can submit a form. - * - * @param \DOMElement $node node - * - * @return bool */ - private function canSubmitForm(\DOMElement $node) + private function canSubmitForm(\DOMElement $node): bool { $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null; @@ -701,12 +585,8 @@ private function canSubmitForm(\DOMElement $node) /** * Determines if a node can reset a form. - * - * @param \DOMElement $node node - * - * @return bool */ - private function canResetForm(\DOMElement $node) + private function canResetForm(\DOMElement $node): bool { $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null; @@ -715,10 +595,8 @@ private function canResetForm(\DOMElement $node) /** * Returns form node unique identifier. - * - * @return string */ - private function getFormNodeId(\DOMElement $form) + private function getFormNodeId(\DOMElement $form): string { return \md5($form->getLineNo().$form->getNodePath().$form->nodeValue); } @@ -726,11 +604,9 @@ private function getFormNodeId(\DOMElement $form) /** * Gets the value of an option element. * - * @return string - * - * @see \Symfony\Component\DomCrawler\Field\ChoiceFormField::buildOptionValue + * @see ChoiceFormField::buildOptionValue() */ - private function getOptionValue(\DOMElement $option) + private function getOptionValue(\DOMElement $option): string { if ($option->hasAttribute('value')) { return $option->getAttribute('value'); @@ -746,14 +622,10 @@ private function getOptionValue(\DOMElement $option) /** * Returns DOMElement from crawler instance. * - * @return \DOMElement - * * @throws DriverException when the node does not exist */ - private function getCrawlerNode(Crawler $crawler) + private function getCrawlerNode(Crawler $crawler): \DOMElement { - $node = null; - if ($crawler instanceof \Iterator) { // for symfony 2.3 compatibility as getNode is not public before symfony 2.4 $crawler->rewind(); @@ -772,13 +644,9 @@ private function getCrawlerNode(Crawler $crawler) /** * Returns a crawler filtered for the given XPath, requiring at least 1 result. * - * @param string $xpath - * - * @return Crawler - * * @throws DriverException when no matching elements are found */ - private function getFilteredCrawler($xpath) + private function getFilteredCrawler(string $xpath): Crawler { if (!\count($crawler = $this->getCrawler()->filterXPath($xpath))) { throw new DriverException(\sprintf('There is no element matching XPath "%s"', $xpath)); @@ -789,20 +657,14 @@ private function getFilteredCrawler($xpath) /** * Returns crawler instance (got from client). - * - * @return Crawler - * - * @throws DriverException */ - private function getCrawler() + private function getCrawler(): Crawler { - $crawler = $this->client->getCrawler(); - - if (null === $crawler) { - throw new DriverException('Unable to access the response content before visiting a page'); + try { + return $this->client->getCrawler(); + } catch (BadMethodCallException $e) { + throw new DriverException('Unable to access the response content before visiting a page', 0, $e); } - - return $crawler; } /** diff --git a/src/Browser/Mink/PantherDriver.php b/src/Browser/Mink/PantherDriver.php index 4f29997..985a4e9 100644 --- a/src/Browser/Mink/PantherDriver.php +++ b/src/Browser/Mink/PantherDriver.php @@ -9,6 +9,7 @@ use Facebook\WebDriver\Internal\WebDriverLocatable; use Facebook\WebDriver\WebDriverElement; use Facebook\WebDriver\WebDriverSelect; +use Symfony\Component\BrowserKit\Exception\BadMethodCallException; use Symfony\Component\DomCrawler\Field\FormField; use Symfony\Component\Panther\Client; use Symfony\Component\Panther\DomCrawler\Crawler; @@ -81,16 +82,9 @@ public function getText($xpath): string return $this->client->getTitle(); } - $text = $crawler->text(); - $text = \str_replace("\n", ' ', $text); - $text = \preg_replace('/ {2,}/', ' ', $text); - - return \trim($text); + return \trim($crawler->text(null, true)); } - /** - * @return mixed - */ public function getValue($xpath) { try { @@ -109,7 +103,7 @@ public function getValue($xpath) return $value; } - public function setValue($xpath, $value) + public function setValue($xpath, $value): void { $element = $this->crawlerElement($this->filteredCrawler($xpath)); $jsNode = $this->jsNode($xpath); @@ -167,6 +161,9 @@ public function selectOption($xpath, $value, $multiple = false): void $select->selectByVisibleText($value); } + /** + * @param string|string[] $path + */ public function attachFile($xpath, $path): void { if (\is_array($path) && empty($this->filteredCrawler($xpath)->attr('multiple'))) { @@ -182,7 +179,7 @@ public function isChecked($xpath): bool { $element = $this->crawlerElement($this->filteredCrawler($xpath)); - if ('radio' === \mb_strtolower($element->getAttribute('type'))) { + if ('radio' === \mb_strtolower((string) $element->getAttribute('type'))) { return null !== $element->getAttribute('checked'); } @@ -195,19 +192,16 @@ public function click($xpath): void $this->client->refreshCrawler(); } - public function executeScript($script) + public function executeScript($script): void { if (\preg_match('/^function[\s(]/', $script)) { $script = \preg_replace('/;$/', '', $script); $script = '('.$script.')'; } - return $this->client->executeScript($script); + $this->client->executeScript($script); } - /** - * @return mixed - */ public function evaluateScript($script) { if (0 !== \mb_strpos(\trim($script), 'return ')) { @@ -220,7 +214,7 @@ public function evaluateScript($script) public function getHtml($xpath): string { // cut the tag itself (making innerHTML out of outerHTML) - return \preg_replace('/^<[^>]+>|<[^>]+>$/', '', $this->getOuterHtml($xpath)); + return (string) \preg_replace('/^<[^>]+>|<[^>]+>$/', '', $this->getOuterHtml($xpath)); } public function isVisible($xpath): bool @@ -230,9 +224,7 @@ public function isVisible($xpath): bool public function getOuterHtml($xpath): string { - $crawler = $this->filteredCrawler($xpath); - - return $crawler->html(); + return $this->filteredCrawler($xpath)->html(); } public function getAttribute($xpath, $name): ?string @@ -264,10 +256,10 @@ private function crawlerElement(Crawler $crawler): WebDriverElement private function prepareUrl(string $url): string { - return \preg_replace('#(https?://[^/]+)(/[^/.]+\.php)?#', '$1$2', $url); + return (string) \preg_replace('#(https?://[^/]+)(/[^/.]+\.php)?#', '$1$2', $url); } - private function filteredCrawler($xpath): Crawler + private function filteredCrawler(string $xpath): Crawler { if (!\count($crawler = $this->crawler()->filterXPath($xpath))) { throw new DriverException(\sprintf('There is no element matching XPath "%s"', $xpath)); @@ -278,11 +270,11 @@ private function filteredCrawler($xpath): Crawler private function crawler(): Crawler { - if (null === $crawler = $this->client->getCrawler()) { - throw new DriverException('Unable to access the response content before visiting a page'); + try { + return $this->client->getCrawler(); + } catch (BadMethodCallException $e) { + throw new DriverException('Unable to access the response content before visiting a page', 0, $e); } - - return $crawler; } private function formField(string $xpath): FormField diff --git a/src/Browser/PantherBrowser.php b/src/Browser/PantherBrowser.php index 47be657..1176bb1 100644 --- a/src/Browser/PantherBrowser.php +++ b/src/Browser/PantherBrowser.php @@ -20,7 +20,11 @@ class PantherBrowser extends Browser private Client $client; private ?string $screenshotDir = null; private ?string $consoleLogDir = null; + + /** @var string[] */ private array $savedScreenshots = []; + + /** @var string[] */ private array $savedConsoleLogs = []; final public function __construct(Client $client) diff --git a/src/Browser/Response/JsonResponse.php b/src/Browser/Response/JsonResponse.php index 0179011..c4ab649 100644 --- a/src/Browser/Response/JsonResponse.php +++ b/src/Browser/Response/JsonResponse.php @@ -11,6 +11,9 @@ */ final class JsonResponse extends Response { + /** + * @return mixed + */ public function json() { if (empty($this->body())) { @@ -29,6 +32,9 @@ public function dump(?string $selector = null): void } } + /** + * @return mixed + */ public function search(string $selector) { return search($selector, $this->json()); diff --git a/src/Browser/Test/BrowserExtension.php b/src/Browser/Test/BrowserExtension.php index 1cdf88a..955c6e5 100644 --- a/src/Browser/Test/BrowserExtension.php +++ b/src/Browser/Test/BrowserExtension.php @@ -18,6 +18,8 @@ final class BrowserExtension implements BeforeFirstTestHook, BeforeTestHook, Aft /** @var Browser[] */ private static array $registeredBrowsers = []; private static bool $enabled = false; + + /** @var array> */ private array $savedArtifacts = []; /**