diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 98c08e1..3400115 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -41,9 +41,52 @@ jobs: - name: Install dependencies run: | - composer require phpunit/phpunit:^${{ matrix.phpunit }} --no-update - composer require "laravel/framework=^${{ matrix.laravel }}" --no-update - composer update --prefer-dist --no-interaction --no-progress + composer update --with=laravel/framework=^${{ matrix.laravel }} --with=phpunit/phpunit:^${{ matrix.phpunit }} --prefer-dist --no-interaction --no-progress - name: Execute tests run: vendor/bin/phpunit + + paratests: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3] + + name: PHP ${{ matrix.php }} Paratest + + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: laravel + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + ini-values: error_reporting=E_ALL + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: | + composer require "nunomaduro/collision" "brianium/paratest" --no-update + composer update --prefer-dist --no-interaction --no-progress -W + + - name: Execute tests + run: php vendor/bin/testbench package:test --parallel + env: + DB_CONNECTION: mysql + DB_USERNAME: root + DB_DATABASE: laravel diff --git a/composer.json b/composer.json index e3310ce..12fe36d 100644 --- a/composer.json +++ b/composer.json @@ -12,13 +12,9 @@ "require": { "php": "^8.2", "ext-dom": "*", - "illuminate/contracts": "^10.0|^11.0", - "illuminate/database": "^10.0|^11.0", - "illuminate/http": "^10.0|^11.0", - "illuminate/support": "^10.0|^11.0", - "illuminate/testing": "^10.0|^11.0", + "laravel/framework": "^10.44|^11.0", "mockery/mockery": "^1.0", - "phpunit/phpunit": "^10.4|^11.0", + "phpunit/phpunit": "^10.4|^11.0.1", "symfony/console": "^6.2|^7.0", "symfony/css-selector": "^6.2|^7.0", "symfony/dom-crawler": "^6.2|^7.0", @@ -26,7 +22,7 @@ "symfony/http-kernel": "^6.2|^7.0" }, "require-dev": { - "laravel/framework": "^10.0|^11.0" + "orchestra/testbench-core": "^8.21|^9.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c98a67a..aec5f6c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,7 +10,7 @@ > - ./tests + ./tests diff --git a/src/Constraints/Concerns/FormFieldConstraint.php b/src/Constraints/Concerns/FormFieldConstraint.php deleted file mode 100644 index fc83ae7..0000000 --- a/src/Constraints/Concerns/FormFieldConstraint.php +++ /dev/null @@ -1,82 +0,0 @@ -selector = $selector; - $this->value = (string) $value; - } - - /** - * Get the valid elements. - * - * Multiple elements should be separated by commas without spaces. - * - * @return string - */ - abstract protected function validElements(); - - /** - * Get the form field. - * - * @param \Symfony\Component\DomCrawler\Crawler $crawler - * @return \Symfony\Component\DomCrawler\Crawler - * - * @throws \PHPUnit\Framework\ExpectationFailedException - */ - protected function field(Crawler $crawler) - { - $field = $crawler->filter(implode(', ', $this->getElements())); - - if ($field->count() > 0) { - return $field; - } - - $this->fail($crawler, sprintf( - 'There is no %s with the name or ID [%s]', - $this->validElements(), $this->selector - )); - } - - /** - * Get the elements relevant to the selector. - * - * @return array - */ - protected function getElements() - { - $name = str_replace('#', '', $this->selector); - - $id = str_replace(['[', ']'], ['\\[', '\\]'], $name); - - return collect(explode(',', $this->validElements()))->map(function ($element) use ($name, $id) { - return "{$element}#{$id}, {$element}[name='{$name}']"; - })->all(); - } -} diff --git a/src/Constraints/Concerns/HasElement.php b/src/Constraints/Concerns/HasElement.php deleted file mode 100644 index e7246b0..0000000 --- a/src/Constraints/Concerns/HasElement.php +++ /dev/null @@ -1,99 +0,0 @@ -selector = $selector; - $this->attributes = $attributes; - } - - /** - * Check if the element is found in the given crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return bool - */ - public function matches($crawler): bool - { - $elements = $this->crawler($crawler)->filter($this->selector); - - if ($elements->count() == 0) { - return false; - } - - if (empty($this->attributes)) { - return true; - } - - $elements = $elements->reduce(function ($element) { - return $this->hasAttributes($element); - }); - - return $elements->count() > 0; - } - - /** - * Determines if the given element has the attributes. - * - * @param \Symfony\Component\DomCrawler\Crawler $element - * @return bool - */ - protected function hasAttributes(Crawler $element) - { - foreach ($this->attributes as $name => $value) { - if (is_numeric($name)) { - if (is_null($element->attr($value))) { - return false; - } - } else { - if ($element->attr($name) != $value) { - return false; - } - } - } - - return true; - } - - /** - * Returns a string representation of the object. - * - * @return string - */ - public function toString(): string - { - $message = "the element [{$this->selector}]"; - - if (! empty($this->attributes)) { - $message .= ' with the attributes '.json_encode($this->attributes); - } - - return $message; - } -} diff --git a/src/Constraints/Concerns/HasInElement.php b/src/Constraints/Concerns/HasInElement.php deleted file mode 100644 index 751e4fd..0000000 --- a/src/Constraints/Concerns/HasInElement.php +++ /dev/null @@ -1,78 +0,0 @@ -text = $text; - $this->element = $element; - } - - /** - * Check if the source or text is found within the element in the given crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return bool - */ - public function matches($crawler): bool - { - $elements = $this->crawler($crawler)->filter($this->element); - - $pattern = $this->getEscapedPattern($this->text); - - foreach ($elements as $element) { - $element = new Crawler($element); - - if (preg_match("/$pattern/i", $element->html())) { - return true; - } - } - - return false; - } - - /** - * Returns the description of the failure. - * - * @return string - */ - protected function getFailureDescription() - { - return sprintf('[%s] contains %s', $this->element, $this->text); - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - return sprintf('[%s] does not contain %s', $this->element, $this->text); - } -} diff --git a/src/Constraints/Concerns/HasLink.php b/src/Constraints/Concerns/HasLink.php deleted file mode 100644 index 1e81908..0000000 --- a/src/Constraints/Concerns/HasLink.php +++ /dev/null @@ -1,116 +0,0 @@ - tag. - * - * @var string|null - */ - protected readonly string|null $url; - - /** - * Create a new constraint instance. - * - * @param string $text - * @param string|null $url - * @return void - */ - public function __construct($text, $url = null) - { - $this->url = $url; - $this->text = $text; - } - - /** - * Check if the link is found in the given crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return bool - */ - public function matches($crawler): bool - { - $links = $this->crawler($crawler)->selectLink($this->text); - - if ($links->count() == 0) { - return false; - } - - // If the URL is null we assume the developer only wants to find a link - // with the given text regardless of the URL. So if we find the link - // we will return true. Otherwise, we will look for the given URL. - if ($this->url == null) { - return true; - } - - $absoluteUrl = $this->absoluteUrl(); - - foreach ($links as $link) { - $linkHref = $link->getAttribute('href'); - - if ($linkHref == $this->url || $linkHref == $absoluteUrl) { - return true; - } - } - - return false; - } - - /** - * Add a root if the URL is relative (helper method of the hasLink function). - * - * @return string - */ - protected function absoluteUrl() - { - if (! Str::startsWith($this->url, ['http', 'https'])) { - return URL::to($this->url); - } - - return $this->url; - } - - /** - * Returns the description of the failure. - * - * @return string - */ - public function getFailureDescription() - { - $description = "has a link with the text [{$this->text}]"; - - if ($this->url) { - $description .= " and the URL [{$this->url}]"; - } - - return $description; - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - $description = "does not have a link with the text [{$this->text}]"; - - if ($this->url) { - $description .= " and the URL [{$this->url}]"; - } - - return $description; - } -} diff --git a/src/Constraints/Concerns/HasSource.php b/src/Constraints/Concerns/HasSource.php deleted file mode 100644 index 10be19f..0000000 --- a/src/Constraints/Concerns/HasSource.php +++ /dev/null @@ -1,47 +0,0 @@ -source = $source; - } - - /** - * Check if the source is found in the given crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return bool - */ - protected function matches($crawler): bool - { - $pattern = $this->getEscapedPattern($this->source); - - return preg_match("/{$pattern}/i", $this->html($crawler)); - } - - /** - * Returns a string representation of the object. - * - * @return string - */ - public function toString(): string - { - return "the HTML [{$this->source}]"; - } -} diff --git a/src/Constraints/Concerns/HasText.php b/src/Constraints/Concerns/HasText.php deleted file mode 100644 index 53bf717..0000000 --- a/src/Constraints/Concerns/HasText.php +++ /dev/null @@ -1,47 +0,0 @@ -text = $text; - } - - /** - * Check if the plain text is found in the given crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return bool - */ - protected function matches($crawler): bool - { - $pattern = $this->getEscapedPattern($this->text); - - return preg_match("/{$pattern}/i", $this->text($crawler)); - } - - /** - * Returns a string representation of the object. - * - * @return string - */ - public function toString(): string - { - return "the text [{$this->text}]"; - } -} diff --git a/src/Constraints/Concerns/HasValue.php b/src/Constraints/Concerns/HasValue.php deleted file mode 100644 index 8b62e9f..0000000 --- a/src/Constraints/Concerns/HasValue.php +++ /dev/null @@ -1,74 +0,0 @@ -crawler($crawler); - - return $this->getInputOrTextAreaValue($crawler) == $this->value; - } - - /** - * Get the value of an input or textarea. - * - * @param \Symfony\Component\DomCrawler\Crawler $crawler - * @return string - * - * @throws \PHPUnit\Framework\ExpectationFailedException - */ - public function getInputOrTextAreaValue(Crawler $crawler) - { - $field = $this->field($crawler); - - return $field->nodeName() == 'input' - ? $field->attr('value') - : $field->text(); - } - - /** - * Return the description of the failure. - * - * @return string - */ - protected function getFailureDescription() - { - return sprintf( - 'the field [%s] contains the expected value [%s]', - $this->selector, $this->value - ); - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - return sprintf( - 'the field [%s] does not contain the expected value [%s]', - $this->selector, $this->value - ); - } -} diff --git a/src/Constraints/Concerns/IsChecked.php b/src/Constraints/Concerns/IsChecked.php deleted file mode 100644 index 4cf376c..0000000 --- a/src/Constraints/Concerns/IsChecked.php +++ /dev/null @@ -1,60 +0,0 @@ -crawler($crawler); - - return ! is_null($this->field($crawler)->attr('checked')); - } - - /** - * Return the description of the failure. - * - * @return string - */ - protected function getFailureDescription() - { - return "the checkbox [{$this->selector}] is checked"; - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - return "the checkbox [{$this->selector}] is not checked"; - } -} diff --git a/src/Constraints/Concerns/IsSelected.php b/src/Constraints/Concerns/IsSelected.php deleted file mode 100644 index 4d32853..0000000 --- a/src/Constraints/Concerns/IsSelected.php +++ /dev/null @@ -1,130 +0,0 @@ -crawler($crawler); - - return in_array($this->value, $this->getSelectedValue($crawler)); - } - - /** - * Get the selected value of a select field or radio group. - * - * @param \Symfony\Component\DomCrawler\Crawler $crawler - * @return array - * - * @throws \PHPUnit\Framework\ExpectationFailedException - */ - public function getSelectedValue(Crawler $crawler) - { - $field = $this->field($crawler); - - return $field->nodeName() == 'select' - ? $this->getSelectedValueFromSelect($field) - : [$this->getCheckedValueFromRadioGroup($field)]; - } - - /** - * Get the selected value from a select field. - * - * @param \Symfony\Component\DomCrawler\Crawler $select - * @return array - */ - protected function getSelectedValueFromSelect(Crawler $select) - { - $selected = []; - - foreach ($select->children() as $option) { - if ($option->nodeName === 'optgroup') { - foreach ($option->childNodes as $child) { - if ($child->hasAttribute('selected')) { - $selected[] = $this->getOptionValue($child); - } - } - } elseif ($option->hasAttribute('selected')) { - $selected[] = $this->getOptionValue($option); - } - } - - return $selected; - } - - /** - * Get the selected value from an option element. - * - * @param \DOMElement $option - * @return string - */ - protected function getOptionValue(DOMElement $option) - { - if ($option->hasAttribute('value')) { - return $option->getAttribute('value'); - } - - return $option->textContent; - } - - /** - * Get the checked value from a radio group. - * - * @param \Symfony\Component\DomCrawler\Crawler $radioGroup - * @return string|null - */ - protected function getCheckedValueFromRadioGroup(Crawler $radioGroup) - { - foreach ($radioGroup as $radio) { - if ($radio->hasAttribute('checked')) { - return $radio->getAttribute('value'); - } - } - } - - /** - * Returns the description of the failure. - * - * @return string - */ - protected function getFailureDescription() - { - return sprintf( - 'the element [%s] has the selected value [%s]', - $this->selector, $this->value - ); - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - return sprintf( - 'the element [%s] does not have the selected value [%s]', - $this->selector, $this->value - ); - } -} diff --git a/src/Constraints/Concerns/PageConstraint.php b/src/Constraints/Concerns/PageConstraint.php deleted file mode 100644 index 0cadce3..0000000 --- a/src/Constraints/Concerns/PageConstraint.php +++ /dev/null @@ -1,123 +0,0 @@ -html() : $crawler; - } - - /** - * Make sure we obtain the HTML from the crawler or the response. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return string - */ - protected function text($crawler) - { - return is_object($crawler) ? $crawler->text() : strip_tags($crawler); - } - - /** - * Create a crawler instance if the given value is not already a Crawler. - * - * @param \Symfony\Component\DomCrawler\Crawler|string $crawler - * @return \Symfony\Component\DomCrawler\Crawler - */ - protected function crawler($crawler) - { - return is_object($crawler) ? $crawler : new Crawler($crawler); - } - - /** - * Get the escaped text pattern for the constraint. - * - * @param string $text - * @return string - */ - protected function getEscapedPattern($text) - { - $rawPattern = preg_quote($text, '/'); - - $escapedPattern = preg_quote(e($text), '/'); - - return $rawPattern == $escapedPattern - ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; - } - - /** - * Throw an exception for the given comparison and test description. - * - * @param mixed $other - * @param string $description - * @param \SebastianBergmann\Comparator\ComparisonFailure|null $comparisonFailure - * @return void - * - * @throws \PHPUnit\Framework\ExpectationFailedException - */ - protected function fail(mixed $other, string $description, ?ComparisonFailure $comparisonFailure = null): never - { - $html = $this->html($other); - - $failureDescription = sprintf( - "%s\n\n\nFailed asserting that %s", - $html, $this->getFailureDescription() - ); - - if (! empty($description)) { - $failureDescription .= ": {$description}"; - } - - if (trim($html) != '') { - $failureDescription .= '. Please check the content above.'; - } else { - $failureDescription .= '. The response is empty.'; - } - - throw new ExpectationFailedException($failureDescription, $comparisonFailure); - } - - /** - * Get the description of the failure. - * - * @return string - */ - protected function getFailureDescription() - { - return 'the page contains '.$this->toString(); - } - - /** - * Returns the reversed description of the failure. - * - * @return string - */ - protected function getReverseFailureDescription() - { - return 'the page does not contain '.$this->toString(); - } - - /** - * Get a string representation of the object. - * - * Placeholder method to avoid forcing definition of this method. - * - * @return string - */ - public function toString(): string - { - return ''; - } -} diff --git a/src/Constraints/Concerns/ReversePageConstraint.php b/src/Constraints/Concerns/ReversePageConstraint.php deleted file mode 100644 index 26142d6..0000000 --- a/src/Constraints/Concerns/ReversePageConstraint.php +++ /dev/null @@ -1,59 +0,0 @@ -pageConstraint = $pageConstraint; - } - - /** - * Reverse the original page constraint result. - * - * @param \Symfony\Component\DomCrawler\Crawler $crawler - * @return bool - */ - public function matches($crawler): bool - { - return ! (fn () => $this->matches($crawler))->call($this->pageConstraint); - } - - /** - * Get the description of the failure. - * - * This method will attempt to negate the original description. - * - * @return string - */ - protected function getFailureDescription() - { - return (fn () => $this->getReverseFailureDescription())->call($this->pageConstraint); - } - - /** - * Get a string representation of the object. - * - * @return string - */ - public function toString(): string - { - return $this->pageConstraint->toString(); - } -} diff --git a/src/Constraints/FormFieldConstraint.php b/src/Constraints/FormFieldConstraint.php index 6be7fdd..75362ae 100644 --- a/src/Constraints/FormFieldConstraint.php +++ b/src/Constraints/FormFieldConstraint.php @@ -2,16 +2,81 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - abstract class FormFieldConstraint extends PageConstraint +abstract class FormFieldConstraint extends PageConstraint +{ + /** + * The name or ID of the element. + * + * @var string + */ + protected readonly string $selector; + + /** + * The expected value. + * + * @var string + */ + protected readonly string $value; + + /** + * Create a new constraint instance. + * + * @param string $selector + * @param mixed $value + * @return void + */ + public function __construct($selector, $value) { - use Concerns\FormFieldConstraint; + $this->selector = $selector; + $this->value = (string) $value; } -} else { - abstract readonly class FormFieldConstraint extends PageConstraint + + /** + * Get the valid elements. + * + * Multiple elements should be separated by commas without spaces. + * + * @return string + */ + abstract protected function validElements(); + + /** + * Get the form field. + * + * @param \Symfony\Component\DomCrawler\Crawler $crawler + * @return \Symfony\Component\DomCrawler\Crawler + * + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + protected function field(Crawler $crawler) { - use Concerns\FormFieldConstraint; + $field = $crawler->filter(implode(', ', $this->getElements())); + + if ($field->count() > 0) { + return $field; + } + + $this->fail($crawler, sprintf( + 'There is no %s with the name or ID [%s]', + $this->validElements(), $this->selector + )); + } + + /** + * Get the elements relevant to the selector. + * + * @return array + */ + protected function getElements() + { + $name = str_replace('#', '', $this->selector); + + $id = str_replace(['[', ']'], ['\\[', '\\]'], $name); + + return collect(explode(',', $this->validElements()))->map(function ($element) use ($name, $id) { + return "{$element}#{$id}, {$element}[name='{$name}']"; + })->all(); } } diff --git a/src/Constraints/HasElement.php b/src/Constraints/HasElement.php index cf22cdb..4ff43cd 100644 --- a/src/Constraints/HasElement.php +++ b/src/Constraints/HasElement.php @@ -2,16 +2,98 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - class HasElement extends PageConstraint +class HasElement extends PageConstraint +{ + /** + * The name or ID of the element. + * + * @var string + */ + protected readonly string $selector; + + /** + * The attributes the element should have. + * + * @var array + */ + protected readonly array $attributes; + + /** + * Create a new constraint instance. + * + * @param string $selector + * @param array $attributes + * @return void + */ + public function __construct($selector, array $attributes = []) + { + $this->selector = $selector; + $this->attributes = $attributes; + } + + /** + * Check if the element is found in the given crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + public function matches($crawler): bool + { + $elements = $this->crawler($crawler)->filter($this->selector); + + if ($elements->count() == 0) { + return false; + } + + if (empty($this->attributes)) { + return true; + } + + $elements = $elements->reduce(function ($element) { + return $this->hasAttributes($element); + }); + + return $elements->count() > 0; + } + + /** + * Determines if the given element has the attributes. + * + * @param \Symfony\Component\DomCrawler\Crawler $element + * @return bool + */ + protected function hasAttributes(Crawler $element) { - use Concerns\HasElement; + foreach ($this->attributes as $name => $value) { + if (is_numeric($name)) { + if (is_null($element->attr($value))) { + return false; + } + } else { + if ($element->attr($name) != $value) { + return false; + } + } + } + + return true; } -} else { - readonly class HasElement extends PageConstraint + + /** + * Returns a string representation of the object. + * + * @return string + */ + public function toString(): string { - use Concerns\HasElement; + $message = "the element [{$this->selector}]"; + + if (! empty($this->attributes)) { + $message .= ' with the attributes '.json_encode($this->attributes); + } + + return $message; } } diff --git a/src/Constraints/HasInElement.php b/src/Constraints/HasInElement.php index d5f17b2..45ffee2 100644 --- a/src/Constraints/HasInElement.php +++ b/src/Constraints/HasInElement.php @@ -2,16 +2,77 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - class HasInElement extends PageConstraint +class HasInElement extends PageConstraint +{ + /** + * The name or ID of the element. + * + * @var string + */ + protected readonly string $element; + + /** + * The text expected to be found. + * + * @var string + */ + protected readonly string $text; + + /** + * Create a new constraint instance. + * + * @param string $element + * @param string $text + * @return void + */ + public function __construct($element, $text) { - use Concerns\HasInElement; + $this->text = $text; + $this->element = $element; } -} else { - readonly class HasInElement extends PageConstraint + + /** + * Check if the source or text is found within the element in the given crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + public function matches($crawler): bool + { + $elements = $this->crawler($crawler)->filter($this->element); + + $pattern = $this->getEscapedPattern($this->text); + + foreach ($elements as $element) { + $element = new Crawler($element); + + if (preg_match("/$pattern/i", $element->html())) { + return true; + } + } + + return false; + } + + /** + * Returns the description of the failure. + * + * @return string + */ + protected function getFailureDescription() + { + return sprintf('[%s] contains %s', $this->element, $this->text); + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() { - use Concerns\HasInElement; + return sprintf('[%s] does not contain %s', $this->element, $this->text); } } diff --git a/src/Constraints/HasLink.php b/src/Constraints/HasLink.php index 676185e..783e509 100644 --- a/src/Constraints/HasLink.php +++ b/src/Constraints/HasLink.php @@ -2,16 +2,115 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use Illuminate\Support\Facades\URL; +use Illuminate\Support\Str; -if (str_starts_with(Version::series(), '10')) { - class HasLink extends PageConstraint +class HasLink extends PageConstraint +{ + /** + * The text expected to be found. + * + * @var string + */ + protected readonly string $text; + + /** + * The URL expected to be linked in the tag. + * + * @var string|null + */ + protected readonly string|null $url; + + /** + * Create a new constraint instance. + * + * @param string $text + * @param string|null $url + * @return void + */ + public function __construct($text, $url = null) + { + $this->url = $url; + $this->text = $text; + } + + /** + * Check if the link is found in the given crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + public function matches($crawler): bool { - use Concerns\HasLink; + $links = $this->crawler($crawler)->selectLink($this->text); + + if ($links->count() == 0) { + return false; + } + + // If the URL is null we assume the developer only wants to find a link + // with the given text regardless of the URL. So if we find the link + // we will return true. Otherwise, we will look for the given URL. + if ($this->url == null) { + return true; + } + + $absoluteUrl = $this->absoluteUrl(); + + foreach ($links as $link) { + $linkHref = $link->getAttribute('href'); + + if ($linkHref == $this->url || $linkHref == $absoluteUrl) { + return true; + } + } + + return false; } -} else { - readonly class HasLink extends PageConstraint + + /** + * Add a root if the URL is relative (helper method of the hasLink function). + * + * @return string + */ + protected function absoluteUrl() { - use Concerns\HasLink; + if (! Str::startsWith($this->url, ['http', 'https'])) { + return URL::to($this->url); + } + + return $this->url; + } + + /** + * Returns the description of the failure. + * + * @return string + */ + public function getFailureDescription() + { + $description = "has a link with the text [{$this->text}]"; + + if ($this->url) { + $description .= " and the URL [{$this->url}]"; + } + + return $description; + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() + { + $description = "does not have a link with the text [{$this->text}]"; + + if ($this->url) { + $description .= " and the URL [{$this->url}]"; + } + + return $description; } } diff --git a/src/Constraints/HasSource.php b/src/Constraints/HasSource.php index 51d20a9..d5a170f 100644 --- a/src/Constraints/HasSource.php +++ b/src/Constraints/HasSource.php @@ -2,16 +2,46 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +class HasSource extends PageConstraint +{ + /** + * The expected HTML source. + * + * @var string + */ + protected readonly string $source; -if (str_starts_with(Version::series(), '10')) { - class HasSource extends PageConstraint + /** + * Create a new constraint instance. + * + * @param string $source + * @return void + */ + public function __construct($source) { - use Concerns\HasSource; + $this->source = $source; } -} else { - readonly class HasSource extends PageConstraint + + /** + * Check if the source is found in the given crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + protected function matches($crawler): bool + { + $pattern = $this->getEscapedPattern($this->source); + + return preg_match("/{$pattern}/i", $this->html($crawler)); + } + + /** + * Returns a string representation of the object. + * + * @return string + */ + public function toString(): string { - use Concerns\HasSource; + return "the HTML [{$this->source}]"; } } diff --git a/src/Constraints/HasText.php b/src/Constraints/HasText.php index ff83f87..a6f463c 100644 --- a/src/Constraints/HasText.php +++ b/src/Constraints/HasText.php @@ -2,16 +2,46 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +class HasText extends PageConstraint +{ + /** + * The expected text. + * + * @var string + */ + protected readonly string $text; -if (str_starts_with(Version::series(), '10')) { - class HasText extends PageConstraint + /** + * Create a new constraint instance. + * + * @param string $text + * @return void + */ + public function __construct($text) { - use Concerns\HasText; + $this->text = $text; } -} else { - readonly class HasText extends PageConstraint + + /** + * Check if the plain text is found in the given crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + protected function matches($crawler): bool + { + $pattern = $this->getEscapedPattern($this->text); + + return preg_match("/{$pattern}/i", $this->text($crawler)); + } + + /** + * Returns a string representation of the object. + * + * @return string + */ + public function toString(): string { - use Concerns\HasText; + return "the text [{$this->text}]"; } } diff --git a/src/Constraints/HasValue.php b/src/Constraints/HasValue.php index b8401f8..f09bcec 100644 --- a/src/Constraints/HasValue.php +++ b/src/Constraints/HasValue.php @@ -2,16 +2,73 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - class HasValue extends FormFieldConstraint +class HasValue extends FormFieldConstraint +{ + /** + * Get the valid elements. + * + * @return string + */ + protected function validElements() { - use Concerns\HasValue; + return 'input,textarea'; } -} else { - readonly class HasValue extends FormFieldConstraint + + /** + * Check if the input contains the expected value. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + public function matches($crawler): bool + { + $crawler = $this->crawler($crawler); + + return $this->getInputOrTextAreaValue($crawler) == $this->value; + } + + /** + * Get the value of an input or textarea. + * + * @param \Symfony\Component\DomCrawler\Crawler $crawler + * @return string + * + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + public function getInputOrTextAreaValue(Crawler $crawler) + { + $field = $this->field($crawler); + + return $field->nodeName() == 'input' + ? $field->attr('value') + : $field->text(); + } + + /** + * Return the description of the failure. + * + * @return string + */ + protected function getFailureDescription() + { + return sprintf( + 'the field [%s] contains the expected value [%s]', + $this->selector, $this->value + ); + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() { - use Concerns\HasValue; + return sprintf( + 'the field [%s] does not contain the expected value [%s]', + $this->selector, $this->value + ); } } diff --git a/src/Constraints/IsChecked.php b/src/Constraints/IsChecked.php index c22f865..b925cd3 100644 --- a/src/Constraints/IsChecked.php +++ b/src/Constraints/IsChecked.php @@ -2,16 +2,59 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +class IsChecked extends FormFieldConstraint +{ + /** + * Create a new constraint instance. + * + * @param string $selector + * @return void + */ + public function __construct($selector) + { + parent::__construct($selector, null); + } + + /** + * Get the valid elements. + * + * @return string + */ + protected function validElements() + { + return "input[type='checkbox']"; + } -if (str_starts_with(Version::series(), '10')) { - class IsChecked extends FormFieldConstraint + /** + * Determine if the checkbox is checked. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + public function matches($crawler): bool { - use Concerns\IsChecked; + $crawler = $this->crawler($crawler); + + return ! is_null($this->field($crawler)->attr('checked')); } -} else { - readonly class IsChecked extends FormFieldConstraint + + /** + * Return the description of the failure. + * + * @return string + */ + protected function getFailureDescription() + { + return "the checkbox [{$this->selector}] is checked"; + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() { - use Concerns\IsChecked; + return "the checkbox [{$this->selector}] is not checked"; } } diff --git a/src/Constraints/IsSelected.php b/src/Constraints/IsSelected.php index 058265e..d228f5b 100644 --- a/src/Constraints/IsSelected.php +++ b/src/Constraints/IsSelected.php @@ -2,16 +2,129 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +use DOMElement; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - class IsSelected extends FormFieldConstraint +class IsSelected extends FormFieldConstraint +{ + /** + * Get the valid elements. + * + * @return string + */ + protected function validElements() { - use Concerns\IsSelected; + return 'select,input[type="radio"]'; } -} else { - readonly class IsSelected extends FormFieldConstraint + + /** + * Determine if the select or radio element is selected. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return bool + */ + protected function matches($crawler): bool + { + $crawler = $this->crawler($crawler); + + return in_array($this->value, $this->getSelectedValue($crawler)); + } + + /** + * Get the selected value of a select field or radio group. + * + * @param \Symfony\Component\DomCrawler\Crawler $crawler + * @return array + * + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + public function getSelectedValue(Crawler $crawler) + { + $field = $this->field($crawler); + + return $field->nodeName() == 'select' + ? $this->getSelectedValueFromSelect($field) + : [$this->getCheckedValueFromRadioGroup($field)]; + } + + /** + * Get the selected value from a select field. + * + * @param \Symfony\Component\DomCrawler\Crawler $select + * @return array + */ + protected function getSelectedValueFromSelect(Crawler $select) + { + $selected = []; + + foreach ($select->children() as $option) { + if ($option->nodeName === 'optgroup') { + foreach ($option->childNodes as $child) { + if ($child->hasAttribute('selected')) { + $selected[] = $this->getOptionValue($child); + } + } + } elseif ($option->hasAttribute('selected')) { + $selected[] = $this->getOptionValue($option); + } + } + + return $selected; + } + + /** + * Get the selected value from an option element. + * + * @param \DOMElement $option + * @return string + */ + protected function getOptionValue(DOMElement $option) + { + if ($option->hasAttribute('value')) { + return $option->getAttribute('value'); + } + + return $option->textContent; + } + + /** + * Get the checked value from a radio group. + * + * @param \Symfony\Component\DomCrawler\Crawler $radioGroup + * @return string|null + */ + protected function getCheckedValueFromRadioGroup(Crawler $radioGroup) + { + foreach ($radioGroup as $radio) { + if ($radio->hasAttribute('checked')) { + return $radio->getAttribute('value'); + } + } + } + + /** + * Returns the description of the failure. + * + * @return string + */ + protected function getFailureDescription() + { + return sprintf( + 'the element [%s] has the selected value [%s]', + $this->selector, $this->value + ); + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() { - use Concerns\IsSelected; + return sprintf( + 'the element [%s] does not have the selected value [%s]', + $this->selector, $this->value + ); } } diff --git a/src/Constraints/PageConstraint.php b/src/Constraints/PageConstraint.php index 6165969..121724b 100644 --- a/src/Constraints/PageConstraint.php +++ b/src/Constraints/PageConstraint.php @@ -3,16 +3,122 @@ namespace Laravel\BrowserKitTesting\Constraints; use PHPUnit\Framework\Constraint\Constraint; -use PHPUnit\Runner\Version; +use PHPUnit\Framework\ExpectationFailedException; +use SebastianBergmann\Comparator\ComparisonFailure; +use Symfony\Component\DomCrawler\Crawler; -if (str_starts_with(Version::series(), '10')) { - abstract class PageConstraint extends Constraint +abstract class PageConstraint extends Constraint +{ + /** + * Make sure we obtain the HTML from the crawler or the response. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return string + */ + protected function html($crawler) { - use Concerns\PageConstraint; + return is_object($crawler) ? $crawler->html() : $crawler; } -} else { - abstract readonly class PageConstraint extends Constraint + + /** + * Make sure we obtain the HTML from the crawler or the response. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return string + */ + protected function text($crawler) + { + return is_object($crawler) ? $crawler->text() : strip_tags($crawler); + } + + /** + * Create a crawler instance if the given value is not already a Crawler. + * + * @param \Symfony\Component\DomCrawler\Crawler|string $crawler + * @return \Symfony\Component\DomCrawler\Crawler + */ + protected function crawler($crawler) + { + return is_object($crawler) ? $crawler : new Crawler($crawler); + } + + /** + * Get the escaped text pattern for the constraint. + * + * @param string $text + * @return string + */ + protected function getEscapedPattern($text) + { + $rawPattern = preg_quote($text, '/'); + + $escapedPattern = preg_quote(e($text), '/'); + + return $rawPattern == $escapedPattern + ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; + } + + /** + * Throw an exception for the given comparison and test description. + * + * @param mixed $other + * @param string $description + * @param \SebastianBergmann\Comparator\ComparisonFailure|null $comparisonFailure + * @return void + * + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + protected function fail(mixed $other, string $description, ?ComparisonFailure $comparisonFailure = null): never + { + $html = $this->html($other); + + $failureDescription = sprintf( + "%s\n\n\nFailed asserting that %s", + $html, $this->getFailureDescription() + ); + + if (! empty($description)) { + $failureDescription .= ": {$description}"; + } + + if (trim($html) != '') { + $failureDescription .= '. Please check the content above.'; + } else { + $failureDescription .= '. The response is empty.'; + } + + throw new ExpectationFailedException($failureDescription, $comparisonFailure); + } + + /** + * Get the description of the failure. + * + * @return string + */ + protected function getFailureDescription() + { + return 'the page contains '.$this->toString(); + } + + /** + * Returns the reversed description of the failure. + * + * @return string + */ + protected function getReverseFailureDescription() + { + return 'the page does not contain '.$this->toString(); + } + + /** + * Get a string representation of the object. + * + * Placeholder method to avoid forcing definition of this method. + * + * @return string + */ + public function toString(): string { - use Concerns\PageConstraint; + return ''; } } diff --git a/src/Constraints/ReversePageConstraint.php b/src/Constraints/ReversePageConstraint.php index 9031e16..790d322 100644 --- a/src/Constraints/ReversePageConstraint.php +++ b/src/Constraints/ReversePageConstraint.php @@ -2,16 +2,56 @@ namespace Laravel\BrowserKitTesting\Constraints; -use PHPUnit\Runner\Version; +class ReversePageConstraint extends PageConstraint +{ + /** + * The page constraint instance. + * + * @var \Laravel\BrowserKitTesting\Constraints\PageConstraint + */ + protected readonly PageConstraint $pageConstraint; -if (str_starts_with(Version::series(), '10')) { - class ReversePageConstraint extends PageConstraint + /** + * Create a new reverse page constraint instance. + * + * @param \Laravel\BrowserKitTesting\Constraints\PageConstraint $pageConstraint + * @return void + */ + public function __construct(PageConstraint $pageConstraint) { - use Concerns\ReversePageConstraint; + $this->pageConstraint = $pageConstraint; } -} else { - readonly class ReversePageConstraint extends PageConstraint + + /** + * Reverse the original page constraint result. + * + * @param \Symfony\Component\DomCrawler\Crawler $crawler + * @return bool + */ + public function matches($crawler): bool + { + return ! (fn () => $this->matches($crawler))->call($this->pageConstraint); + } + + /** + * Get the description of the failure. + * + * This method will attempt to negate the original description. + * + * @return string + */ + protected function getFailureDescription() + { + return (fn () => $this->getReverseFailureDescription())->call($this->pageConstraint); + } + + /** + * Get a string representation of the object. + * + * @return string + */ + public function toString(): string { - use Concerns\ReversePageConstraint; + return $this->pageConstraint->toString(); } } diff --git a/src/TestCase.php b/src/TestCase.php index 661cee5..9831fdd 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -3,16 +3,8 @@ namespace Laravel\BrowserKitTesting; use Illuminate\Contracts\Console\Kernel; -use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Application; -use Illuminate\Foundation\Testing\DatabaseMigrations; -use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\WithoutEvents; -use Illuminate\Foundation\Testing\WithoutMiddleware; -use Illuminate\Support\Facades\Facade; -use Mockery; +use Illuminate\Foundation\Testing\Concerns\InteractsWithTestCaseLifecycle; use PHPUnit\Framework\TestCase as BaseTestCase; use RuntimeException; @@ -25,35 +17,8 @@ abstract class TestCase extends BaseTestCase Concerns\InteractsWithConsole, Concerns\InteractsWithDatabase, Concerns\InteractsWithExceptionHandling, - Concerns\InteractsWithSession; - - /** - * The Illuminate application instance. - * - * @var \Illuminate\Foundation\Application - */ - protected $app; - - /** - * The callbacks that should be run after the application is created. - * - * @var array - */ - protected $afterApplicationCreatedCallbacks = []; - - /** - * The callbacks that should be run before the application is destroyed. - * - * @var array - */ - protected $beforeApplicationDestroyedCallbacks = []; - - /** - * Indicates if we have made it through the base setUp function. - * - * @var bool - */ - protected $setUpHasRun = false; + Concerns\InteractsWithSession, + InteractsWithTestCaseLifecycle; /** * Creates the application. @@ -82,21 +47,7 @@ public function createApplication() */ protected function setUp(): void { - if (! $this->app) { - $this->refreshApplication(); - } - - $this->setUpTraits(); - - foreach ($this->afterApplicationCreatedCallbacks as $callback) { - call_user_func($callback); - } - - Facade::clearResolvedInstances(); - - Model::setEventDispatcher($this->app['events']); - - $this->setUpHasRun = true; + $this->setUpTheTestEnvironment(); } /** @@ -111,42 +62,6 @@ protected function refreshApplication() $this->app = $this->createApplication(); } - /** - * Boot the testing helper traits. - * - * @return array - */ - protected function setUpTraits() - { - $uses = array_flip(class_uses_recursive(static::class)); - - if (isset($uses[RefreshDatabase::class])) { - $this->refreshDatabase(); - } - - if (isset($uses[DatabaseMigrations::class])) { - $this->runDatabaseMigrations(); - } - - if (isset($uses[DatabaseTransactions::class])) { - $this->beginDatabaseTransaction(); - } - - if (isset($uses[WithoutMiddleware::class])) { - $this->disableMiddlewareForAllTests(); - } - - if (isset($uses[WithoutEvents::class])) { - $this->disableEventsForAllTests(); - } - - if (isset($uses[WithFaker::class])) { - $this->setUpFaker(); - } - - return $uses; - } - /** * Clean up the testing environment before the next test. * @@ -154,57 +69,6 @@ protected function setUpTraits() */ protected function tearDown(): void { - if ($this->app) { - foreach ($this->beforeApplicationDestroyedCallbacks as $callback) { - call_user_func($callback); - } - - $this->app->flush(); - - $this->app = null; - } - - $this->setUpHasRun = false; - - if (property_exists($this, 'serverVariables')) { - $this->serverVariables = []; - } - - if (class_exists('Mockery')) { - if ($container = Mockery::getContainer()) { - $this->addToAssertionCount($container->mockery_getExpectationCount()); - } - - Mockery::close(); - } - - $this->afterApplicationCreatedCallbacks = []; - $this->beforeApplicationDestroyedCallbacks = []; - } - - /** - * Register a callback to be run after the application is created. - * - * @param callable $callback - * @return void - */ - public function afterApplicationCreated(callable $callback) - { - $this->afterApplicationCreatedCallbacks[] = $callback; - - if ($this->setUpHasRun) { - call_user_func($callback); - } - } - - /** - * Register a callback to be run before the application is destroyed. - * - * @param callable $callback - * @return void - */ - protected function beforeApplicationDestroyed(callable $callback) - { - $this->beforeApplicationDestroyedCallbacks[] = $callback; + $this->tearDownTheTestEnvironment(); } } diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php new file mode 100644 index 0000000..2c1c5a9 --- /dev/null +++ b/tests/CreatesApplication.php @@ -0,0 +1,23 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/tests/Feature/ParallelTestingTest.php b/tests/Feature/ParallelTestingTest.php new file mode 100644 index 0000000..c3475c1 --- /dev/null +++ b/tests/Feature/ParallelTestingTest.php @@ -0,0 +1,31 @@ +markTestSkipped('Requires paratest to execute the tests'); + } + + parent::setUp(); + } + + public function test_database_connection_name() + { + $databaseName = (new User)->getConnection()->getDatabaseName(); + + $this->assertStringContainsString('_test_', $databaseName); + } +} diff --git a/tests/TestCaseTest.php b/tests/TestCaseTest.php index 0ac8e72..1f4f2b6 100644 --- a/tests/TestCaseTest.php +++ b/tests/TestCaseTest.php @@ -7,13 +7,7 @@ class TestCaseTest extends TestCase { - /** - * {@inheritdoc} - */ - public function createApplication() - { - return new Application(); - } + use CreatesApplication; public function test_refresh_application() {