From 40d05ede9bc81004a1a6389f059733a9957a0023 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:07:20 -0500 Subject: [PATCH 01/13] Introduce the Selector class, deprecate flattenAttributeArray --- src/Exceptions/AttributeArrayException.php | 10 ++ src/MarkupAssertionsTrait.php | 23 ++--- src/Selector.php | 81 +++++++++++++++++ tests/MarkupAssertionsTraitTest.php | 52 +---------- tests/SelectorTest.php | 101 +++++++++++++++++++++ 5 files changed, 203 insertions(+), 64 deletions(-) create mode 100644 src/Exceptions/AttributeArrayException.php create mode 100644 src/Selector.php create mode 100644 tests/SelectorTest.php diff --git a/src/Exceptions/AttributeArrayException.php b/src/Exceptions/AttributeArrayException.php new file mode 100644 index 0000000..55731c4 --- /dev/null +++ b/src/Exceptions/AttributeArrayException.php @@ -0,0 +1,10 @@ + $attributes HTML attributes and their values. * * @return string A XPath attribute query selector. + * + * @deprecated since 2.0.0. Use the Selector object instead. + * This method will be removed in a future release! + * + * @codeCoverageIgnore */ private function flattenAttributeArray(array $attributes) { - if (empty($attributes)) { - throw new RiskyTestError('Attributes array is empty.'); + try { + return (new Selector($attributes))->getValue(); + } catch (AttributeArrayException $e) { + throw new RiskyTestError($e->getMessage(), $e->getCode(), $e); } - - array_walk($attributes, function (&$value, $key) { - // Boolean attributes. - if (null === $value) { - $value = sprintf('[%s]', $key); - } else { - $value = sprintf('[%s="%s"]', $key, htmlspecialchars($value)); - } - }); - - return implode('', $attributes); } /** diff --git a/src/Selector.php b/src/Selector.php new file mode 100644 index 0000000..0b0f671 --- /dev/null +++ b/src/Selector.php @@ -0,0 +1,81 @@ + $selector Either a CSS selector string or an array of + * attributes, the latter of which will be flattened + * automatically into an XPath attribute query. + */ + public function __construct($selector) + { + if (is_array($selector)) { + $selector = $this->attributeArrayToString($selector); + } + + $this->selector = $selector; + } + + /** + * Magic method to enable Selectors to be cast to strings. + * + * @return string + */ + public function __toString() + { + return $this->getValue(); + } + + /** + * Retrieve the selector string. + * + * @return string + */ + public function getValue() + { + return $this->selector; + } + + /** + * Given an array of attributes, flatten them into a CSS-compatible syntax. + * + * @param array $attributes An array of attributes to flatten into an XPath + * attribute query path. + * + * @return string The flattened attribute array. + */ + private function attributeArrayToString($attributes) + { + if (empty($attributes)) { + throw new AttributeArrayException('Attributes array is empty.'); + } + + array_walk($attributes, function (&$value, $key) { + // Boolean attributes. + if (null === $value) { + $value = sprintf('[%s]', $key); + } else { + $value = sprintf('[%s="%s"]', $key, htmlspecialchars($value)); + } + }); + + return '*' . implode('', $attributes); + } +} diff --git a/tests/MarkupAssertionsTraitTest.php b/tests/MarkupAssertionsTraitTest.php index 0cacc7f..b1158c6 100644 --- a/tests/MarkupAssertionsTraitTest.php +++ b/tests/MarkupAssertionsTraitTest.php @@ -236,23 +236,11 @@ public function testAssertElementNotRegExp() } - /** - * @test - * @testdox flattenAttributeArray() should flatten an array of attributes - * @dataProvider provideAttributes - */ - public function flattenArrayAttribute_should_flatten_arrays_of_attributes($attributes, $expected) - { - $method = new \ReflectionMethod($this, 'flattenAttributeArray'); - $method->setAccessible(true); - $this->assertSame($expected, $method->invoke($this, $attributes)); - } /** * @test * @testdox flattenAttributeArray() should throw a RiskyTestError if the array is empty - * @dataProvider provideAttributes */ public function flattenAttributeArray_should_throw_a_RiskyTestError_if_given_an_empty_array() { @@ -276,45 +264,7 @@ public function getInnerHtmlOfMatchedElements_should_retrieve_the_inner_HTML($ma $this->assertEquals($expected, $method->invoke($this, $markup, $selector)); } - /** - * Data provider for testFlattenAttributeArray(). - */ - public function provideAttributes() - { - return [ - 'Single attribute' => [ - [ - 'id' => 'first-name', - ], - '[id="first-name"]', - ], - 'Multiple attributes' => [ - [ - 'id' => 'first-name', - 'value' => 'Ringo', - ], - '[id="first-name"][value="Ringo"]', - ], - 'Boolean attribute' => [ - [ - 'checked' => null, - ], - '[checked]', - ], - 'Data attribute' => [ - [ - 'data-foo' => 'bar', - ], - '[data-foo="bar"]', - ], - 'Value contains quotes' => [ - [ - 'name' => 'Austin "Danger" Powers', - ], - '[name="Austin "Danger" Powers"]', - ], - ]; - } + /** * Data provider for testGetInnerHtmlOfMatchedElements(). diff --git a/tests/SelectorTest.php b/tests/SelectorTest.php new file mode 100644 index 0000000..6a09825 --- /dev/null +++ b/tests/SelectorTest.php @@ -0,0 +1,101 @@ +assertSame('a.some-class', $selector->getValue()); + } + + /** + * @test + * + * @dataProvider provideAttributes + */ + public function it_should_automatically_convert_attribute_arrays_to_strings($attributes, $expected) + { + $selector = new Selector($attributes); + + $this->assertSame($expected, $selector->getValue()); + } + + /** + * @test + */ + public function it_should_throw_if_unable_to_handle_attribute_array() + { + $this->expectException(AttributeArrayException::class); + + new Selector([]); + } + + /** + * @test + */ + public function it_should_be_able_to_be_cast_to_a_string() + { + $selector = new Selector('a.some-class'); + + $this->assertSame('a.some-class', (string) $selector); + } + + /** + * Data provider for testFlattenAttributeArray(). + * + * @return Iterable{array, string} The attribute array and teh expected string. + */ + public function provideAttributes() + { + yield 'Single attribute' => [ + [ + 'id' => 'first-name', + ], + '*[id="first-name"]', + ]; + + yield 'Multiple attributes' => [ + [ + 'id' => 'first-name', + 'value' => 'Ringo', + ], + '*[id="first-name"][value="Ringo"]', + ]; + + yield 'Boolean attribute' => [ + [ + 'checked' => null, + ], + '*[checked]', + ]; + + yield 'Data attribute' => [ + [ + 'data-foo' => 'bar', + ], + '*[data-foo="bar"]', + ]; + + yield 'Value contains quotes' => [ + [ + 'name' => 'Austin "Danger" Powers', + ], + '*[name="Austin "Danger" Powers"]', + ]; + } +} From 00d2513b4047b1e357ffecba1991f0831d6a50bf Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:17:19 -0500 Subject: [PATCH 02/13] Split the test suite into integration tests (the trait itself) and unit tests (individual classes) --- phpunit.xml.dist | 5 +++- .../AssertionsTest.php} | 24 ++++++------------- tests/{ => Unit}/SelectorTest.php | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) rename tests/{MarkupAssertionsTraitTest.php => Integration/AssertionsTest.php} (95%) rename tests/{ => Unit}/SelectorTest.php (99%) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 86843a1..6d2f59f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,7 +10,10 @@ stopOnFailure="false"> - ./tests + ./tests/Unit + + + ./tests/Integration diff --git a/tests/MarkupAssertionsTraitTest.php b/tests/Integration/AssertionsTest.php similarity index 95% rename from tests/MarkupAssertionsTraitTest.php rename to tests/Integration/AssertionsTest.php index b1158c6..46b5008 100644 --- a/tests/MarkupAssertionsTraitTest.php +++ b/tests/Integration/AssertionsTest.php @@ -1,6 +1,6 @@ expectException(RiskyTestError::class); - - $method = new \ReflectionMethod($this, 'flattenAttributeArray'); - $method->setAccessible(true); - $method->invoke($this, []); - } - /** * @test * @testdox getInnerHtmlOfMatchedElements() should retrieve the inner HTML diff --git a/tests/SelectorTest.php b/tests/Unit/SelectorTest.php similarity index 99% rename from tests/SelectorTest.php rename to tests/Unit/SelectorTest.php index 6a09825..5ce0fc9 100644 --- a/tests/SelectorTest.php +++ b/tests/Unit/SelectorTest.php @@ -1,6 +1,6 @@ Date: Sun, 3 Dec 2023 15:18:06 -0500 Subject: [PATCH 03/13] Introduce SelectorException, which acts as a base for AttributeArrayException --- src/Exceptions/AttributeArrayException.php | 2 +- src/Exceptions/SelectorException.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/SelectorException.php diff --git a/src/Exceptions/AttributeArrayException.php b/src/Exceptions/AttributeArrayException.php index 55731c4..8b55566 100644 --- a/src/Exceptions/AttributeArrayException.php +++ b/src/Exceptions/AttributeArrayException.php @@ -5,6 +5,6 @@ /** * Thrown when unable to convert an attribute array into a CSS selector string. */ -class AttributeArrayException extends \InvalidArgumentException +class AttributeArrayException extends SelectorException { } diff --git a/src/Exceptions/SelectorException.php b/src/Exceptions/SelectorException.php new file mode 100644 index 0000000..40cdebe --- /dev/null +++ b/src/Exceptions/SelectorException.php @@ -0,0 +1,7 @@ + Date: Sun, 3 Dec 2023 15:18:50 -0500 Subject: [PATCH 04/13] Define custom constraints, which give us more control over how they work --- src/Constraints/ContainsSelector.php | 48 ++++++++++ src/Constraints/ElementContainsString.php | 91 +++++++++++++++++++ src/Constraints/SelectorCount.php | 59 ++++++++++++ .../Unit/Constraints/ContainsSelectorTest.php | 76 ++++++++++++++++ .../Constraints/ElementContainsStringTest.php | 86 ++++++++++++++++++ tests/Unit/Constraints/SelectorCountTest.php | 83 +++++++++++++++++ 6 files changed, 443 insertions(+) create mode 100644 src/Constraints/ContainsSelector.php create mode 100644 src/Constraints/ElementContainsString.php create mode 100644 src/Constraints/SelectorCount.php create mode 100644 tests/Unit/Constraints/ContainsSelectorTest.php create mode 100644 tests/Unit/Constraints/ElementContainsStringTest.php create mode 100644 tests/Unit/Constraints/SelectorCountTest.php diff --git a/src/Constraints/ContainsSelector.php b/src/Constraints/ContainsSelector.php new file mode 100644 index 0000000..eb20858 --- /dev/null +++ b/src/Constraints/ContainsSelector.php @@ -0,0 +1,48 @@ +selector = $selector; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return 'contains selector ' . $this->exporter()->export($this->selector->getValue()); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $other value or object to evaluate + * + * @return bool + */ + protected function matches($value): bool + { + $dom = new DOM($value); + + return $dom->countInstancesOfSelector($this->selector) > 0; + } +} diff --git a/src/Constraints/ElementContainsString.php b/src/Constraints/ElementContainsString.php new file mode 100644 index 0000000..74ddb8d --- /dev/null +++ b/src/Constraints/ElementContainsString.php @@ -0,0 +1,91 @@ +selector = $selector; + $this->needle = $needle; + $this->ignore_case = $ignore_case; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + 'contains string %s', + $this->exporter()->export($this->needle) + ); + } + + /** + * {@inheritDoc} + * + * @param mixed $other evaluated value or object + * + * @return string + */ + protected function failureDescription($other): string + { + return sprintf( + 'element with selector %s in %s %s', + $this->exporter()->export($this->selector->getValue()), + $this->exporter()->export($other), + $this->toString() + ); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $other value or object to evaluate + * + * @return bool + */ + protected function matches($value): bool + { + $dom = new DOM($value); + $fn = $this->ignore_case ? 'stripos' : 'strpos'; + + // Iterate through each matching element and look for the text. + foreach ($dom->getInnerHtml($this->selector) as $html) { + if ($fn($html, $this->needle) !== false) { + return true; + } + } + + return false; + } +} diff --git a/src/Constraints/SelectorCount.php b/src/Constraints/SelectorCount.php new file mode 100644 index 0000000..f0495ac --- /dev/null +++ b/src/Constraints/SelectorCount.php @@ -0,0 +1,59 @@ +selector = $selector; + $this->count = $count; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + 'contains %d instance(s) of selector %s', + $this->count, + $this->exporter()->export($this->selector->getValue()) + ); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $other value or object to evaluate + * + * @return bool + */ + protected function matches($value): bool + { + $dom = new DOM($value); + + return $dom->countInstancesOfSelector($this->selector) === $this->count; + } +} diff --git a/tests/Unit/Constraints/ContainsSelectorTest.php b/tests/Unit/Constraints/ContainsSelectorTest.php new file mode 100644 index 0000000..c9bd4f3 --- /dev/null +++ b/tests/Unit/Constraints/ContainsSelectorTest.php @@ -0,0 +1,76 @@ +Example'; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + * @dataProvider provideSelectorVariants + */ + public function it_should_not_find_unmatched_selectors_in_content($selector) + { + $constraint = new ContainsSelector(new Selector($selector)); + $html = '

This element has little to do with the link.

'; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $selector = new Selector('p.body'); + $html = '

Some Title

'; + + try { + (new ContainsSelector($selector))->evaluate($html); + } catch (\Exception $e) { + $this->assertSame( + "Failed asserting that '{$html}' contains selector '{$selector}'.", + $e->getMessage() + ); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * Data provider for testAssertContainsSelector(). + * + * @return Iterable> + */ + public function provideSelectorVariants() + { + yield 'Simple tag name' => ['a']; + yield 'Class name' => ['.link']; + yield 'Multiple class names' => ['.link.another-class']; + yield 'Element ID' => ['#my-link']; + yield 'Tag name with class' => ['a.link']; + yield 'Tag name with ID' => ['a#my-link']; + yield 'Tag with href attribute' => ['a[href="https://example.com"]']; + } +} diff --git a/tests/Unit/Constraints/ElementContainsStringTest.php b/tests/Unit/Constraints/ElementContainsStringTest.php new file mode 100644 index 0000000..23ff433 --- /dev/null +++ b/tests/Unit/Constraints/ElementContainsStringTest.php @@ -0,0 +1,86 @@ +Title

This is the content

'; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_match_in_a_case_sensitive_manner_by_default() + { + $constraint = new ElementContainsString(new Selector('p'), 'THIS IS THE CONTENT'); + $html = '

Title

This is the content

'; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'By default, searches should be case-sensitive.' + ); + } + + /** + * @test + */ + public function it_should_be_able_to_ignore_case() + { + $constraint = new ElementContainsString(new Selector('p'), 'THIS IS THE CONTENT', true); + $html = '

Title

This is the content

'; + + $this->assertTrue( + $constraint->evaluate($html, '', true), + 'When $ignore_case is true, searches should be case-insensitive.' + ); + } + + /** + * @test + */ + public function it_should_fail_if_no_match_is_found() + { + $constraint = new ElementContainsString(new Selector('p'), 'This is the content'); + $html = '

This is the content

But this is not

'; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $selector = new Selector('p.body'); + $html = '

Some other string

'; + + try { + (new ElementContainsString($selector, 'some string'))->evaluate($html); + } catch (\Exception $e) { + $this->assertSame( + "Failed asserting that element with selector '{$selector}' in '{$html}' contains string 'some string'.", + $e->getMessage() + ); + return; + } + + $this->fail('Did not receive the expected error message.'); + } +} diff --git a/tests/Unit/Constraints/SelectorCountTest.php b/tests/Unit/Constraints/SelectorCountTest.php new file mode 100644 index 0000000..d28b61d --- /dev/null +++ b/tests/Unit/Constraints/SelectorCountTest.php @@ -0,0 +1,83 @@ +assertTrue($constraint->evaluate($markup, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $selector = new Selector('p.body'); + $html = '

Some Title

'; + + try { + (new SelectorCount($selector, 5))->evaluate($html); + } catch (\Exception $e) { + $this->assertSame( + "Failed asserting that '{$html}' contains 5 instance(s) of selector '{$selector}'.", + $e->getMessage() + ); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * Data provider for testAssertContainsSelector(). + * + * @return Iterable + */ + public function provideMarkupVariants() + { + yield 'Simple count' => [ + '

This is a title

Content

', + new Selector('h1'), + 1, + ]; + + yield 'Multiple siblings' => [ + '
  • 1
  • 2
  • 3
', + new Selector('li'), + 3 + ]; + + yield 'Nested elements with low specificity' => [ + '
Hello
There
', + new Selector('div'), + 2, + ]; + + yield 'Nested elements with high-specificity' => [ + '
Hello
There
', + new Selector('div>div'), + 1, + ]; + } +} From 76f316334503b354f764686a24594450b483b4f6 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:19:48 -0500 Subject: [PATCH 05/13] Define a DOM class, which wraps Symfony and removes private, internal methods from the trait --- src/DOM.php | 68 +++++++++++++++++++++ src/MarkupAssertionsTrait.php | 79 ++++++++++-------------- tests/Unit/DOMTest.php | 112 ++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 47 deletions(-) create mode 100644 src/DOM.php create mode 100644 tests/Unit/DOMTest.php diff --git a/src/DOM.php b/src/DOM.php new file mode 100644 index 0000000..5378bcf --- /dev/null +++ b/src/DOM.php @@ -0,0 +1,68 @@ +crawler = new Crawler($markup); + } + + /** + * Count the number of matches for $selector we find in $this->crawler. + * + * @param Selector $selector The query selector. + * + * @return int + */ + public function countInstancesOfSelector(Selector $selector) + { + return count($this->query($selector)); + } + + /** + * Retrieve the inner contents of elements matching the given selector. + * + * @param Selector $selector The query selector. + * + * @return array The inner contents of the matched selector. Each match is a separate + * value in the array. + */ + public function getInnerHtml(Selector $selector) + { + return $this->query($selector)->each(function ($element) { + return $element->html(); + }); + } + + /** + * @param Selector $selector The query selector. + * + * @return Crawler A filtered version of $this->crawler. + */ + public function query(Selector $selector) + { + try { + return $this->crawler->filter($selector->getValue()); + } catch (SyntaxErrorException $e) { + throw new SelectorException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/MarkupAssertionsTrait.php b/src/MarkupAssertionsTrait.php index 5d8e316..a67ec0f 100644 --- a/src/MarkupAssertionsTrait.php +++ b/src/MarkupAssertionsTrait.php @@ -9,7 +9,11 @@ namespace SteveGrunwell\PHPUnit_Markup_Assertions; +use PHPUnit\Framework\Constraint\LogicalNot; use PHPUnit\Framework\RiskyTestError; +use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ContainsSelector; +use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ElementContainsString; +use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\SelectorCount; use SteveGrunwell\PHPUnit_Markup_Assertions\Exceptions\AttributeArrayException; use Symfony\Component\DomCrawler\Crawler; @@ -28,9 +32,9 @@ trait MarkupAssertionsTrait */ public function assertContainsSelector($selector, $markup = '', $message = '') { - $results = $this->executeDomQuery($markup, $selector); + $constraint = new ContainsSelector(new Selector($selector)); - $this->assertGreaterThan(0, count($results), $message); + static::assertThat($markup, $constraint, $message); } /** @@ -46,9 +50,9 @@ public function assertContainsSelector($selector, $markup = '', $message = '') */ public function assertNotContainsSelector($selector, $markup = '', $message = '') { - $results = $this->executeDomQuery($markup, $selector); + $constraint = new LogicalNot(new ContainsSelector(new Selector($selector))); - $this->assertEquals(0, count($results), $message); + static::assertThat($markup, $constraint, $message); } /** @@ -65,9 +69,9 @@ public function assertNotContainsSelector($selector, $markup = '', $message = '' */ public function assertSelectorCount($count, $selector, $markup = '', $message = '') { - $results = $this->executeDomQuery($markup, $selector); + $constraint = new SelectorCount(new Selector($selector), $count); - $this->assertCount($count, $results, $message); + static::assertThat($markup, $constraint, $message); } /** @@ -85,11 +89,9 @@ public function assertSelectorCount($count, $selector, $markup = '', $message = */ public function assertHasElementWithAttributes($attributes = [], $markup = '', $message = '') { - $this->assertContainsSelector( - '*' . $this->flattenAttributeArray($attributes), - $markup, - $message - ); + $constraint = new ContainsSelector(new Selector($attributes)); + + static::assertThat($markup, $constraint, $message); } /** @@ -107,11 +109,9 @@ public function assertHasElementWithAttributes($attributes = [], $markup = '', $ */ public function assertNotHasElementWithAttributes($attributes = [], $markup = '', $message = '') { - $this->assertNotContainsSelector( - '*' . $this->flattenAttributeArray($attributes), - $markup, - $message - ); + $constraint = new LogicalNot(new ContainsSelector(new Selector($attributes))); + + static::assertThat($markup, $constraint, $message); } /** @@ -128,15 +128,9 @@ public function assertNotHasElementWithAttributes($attributes = [], $markup = '' */ public function assertElementContains($contents, $selector = '', $markup = '', $message = '') { - $method = method_exists($this, 'assertStringContainsString') - ? 'assertStringContainsString' - : 'assertContains'; // @codeCoverageIgnore + $constraint = new ElementContainsString(new Selector($selector), $contents); - $this->$method( - $contents, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + static::assertThat($markup, $constraint, $message); } /** @@ -153,15 +147,9 @@ public function assertElementContains($contents, $selector = '', $markup = '', $ */ public function assertElementNotContains($contents, $selector = '', $markup = '', $message = '') { - $method = method_exists($this, 'assertStringNotContainsString') - ? 'assertStringNotContainsString' - : 'assertNotContains'; // @codeCoverageIgnore + $constraint = new LogicalNot(new ElementContainsString(new Selector($selector), $contents)); - $this->$method( - $contents, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + static::assertThat($markup, $constraint, $message); } /** @@ -223,12 +211,15 @@ public function assertElementNotRegExp($regexp, $selector = '', $markup = '', $m * @param string $query The DOM selector query. * * @return Crawler + * + * @deprecated since 2.0.0. Use {@see DOM::query()} instead. + * This method will be removed in a future release! + * + * @codeCoverageIgnore */ private function executeDomQuery($markup, $query) { - $dom = new Crawler($markup); - - return $dom->filter($query); + return (new DOM($markup))->query(new Selector($query)); } /** @@ -265,20 +256,14 @@ private function flattenAttributeArray(array $attributes) * @param string $query The DOM selector query. * * @return string The concatenated innerHTML of any matched selectors. + * + * @deprecated since 2.0.0. SOME OTHER ALTERNATIVE + * This method will be removed in a future release! + * + * @codeCoverageIgnore */ private function getInnerHtmlOfMatchedElements($markup, $query) { - $results = $this->executeDomQuery($markup, $query); - $contents = []; - - // Loop through results and collect their innerHTML values. - foreach ($results as $result) { - $document = new \DOMDocument(); - $document->appendChild($document->importNode($result->firstChild, true)); - - $contents[] = trim(html_entity_decode($document->saveHTML())); - } - - return implode(PHP_EOL, $contents); + return implode(PHP_EOL, (new DOM($markup))->getInnerHtml(new Selector($query))); } } diff --git a/tests/Unit/DOMTest.php b/tests/Unit/DOMTest.php new file mode 100644 index 0000000..c741e6e --- /dev/null +++ b/tests/Unit/DOMTest.php @@ -0,0 +1,112 @@ +assertSame($expected, $dom->countInstancesOfSelector($selector)); + } + + /** + * @test + * @testdox getInnerHtml() should retrieve the inner HTML for each matching element. + */ + public function getInnerHtml_should_retrieve_the_inner_HTML_for_each_matching_element() + { + $markup = <<<'HTML' +
    +
  • The strong element
  • +
  • The em element
  • +
  • The kbd element
  • +
+ HTML; + $dom = new DOM($markup); + + $this->assertSame( + [ + 'The strong element', + 'The em element', + 'The kbd element', + ], + $dom->getInnerHtml(new Selector('li')), + ); + } + + /** + * @test + * @testdox getInnerHtml() should return an empty array if there are no matches + */ + public function getInnerHtml_should_return_an_empty_array_if_there_are_no_matches() + { + $dom = new DOM('

A title

'); + + $this->assertEmpty($dom->getInnerHtml(new Selector('h2'))); + } + + /** + * @test + * @testdox query() should throw a SelectorException if the selector is invalid + */ + public function query_should_throw_a_SelectorException_if_the_selector_is_invalid() + { + $dom = new DOM('

Some markup

'); + $selector = new Selector('#'); + + try { + $dom->query($selector); + } catch (\Exception $e) { + $this->assertInstanceOf(SelectorException::class, $e); + return; + } + + $this->fail('Failed to catch a SelectorException from invalid selector "#".'); + } + + /** + * Return test cases with varying numbers of .inner elements. + * + * @return Iterable + */ + public function provideMarkupWithInnerClass() + { + yield 'No matches' => [ + '
', + 0, + ]; + + yield 'One match' => [ + '
Hello
', + 1, + ]; + + yield 'Two matches' => [ + <<<'HTML' +
+
One
+
Two
+
+ HTML, + 2 + ]; + } +} From bf4546ca4752b9dee42ae9e6a4d141916b19951c Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:38:54 -0500 Subject: [PATCH 06/13] Drop PHP 5.6 support --- .github/workflows/unit-tests.yml | 2 +- .phpcs.xml.dist | 2 +- README.md | 8 +++++++- composer.json | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e6f3aea..3435a7c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] + php-version: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 57b3de6..148f4a5 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -20,5 +20,5 @@ - + diff --git a/README.md b/README.md index 8915add..497b424 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,13 @@ class MyUnitTest extends TestCase To add PHPUnit Markup Assertions to your project, first install the library via Composer: ```sh -$ composer require --dev stevegrunwell/phpunit-markup-assertions +composer require --dev stevegrunwell/phpunit-markup-assertions +``` + +Please note that if you need to execute these against a PHP 5.6 codebase, you'll need to use version 1.x: + +```sh +composer require --dev stevegrunwell/phpunit-markup-assertions:^1.0 ``` Next, import the `SteveGrunwell\PHPUnit_Markup_Assertions\MarkupAssertionsTrait` trait into each test case that will leverage the assertions: diff --git a/composer.json b/composer.json index 0e10181..7e6f417 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ "source": "https://github.com/stevegrunwell/phpunit-markup-assertions/" }, "require": { - "php": "^5.6 || ^7.0 || ^8.0", - "symfony/css-selector": "^3.4|^4.4|^5.4|^6.0", - "symfony/dom-crawler": "^3.4|^4.4|^5.4|^6.0" + "php": "^7.0 || ^8.0", + "symfony/css-selector": "^3.4|^4.4|^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^3.4|^4.4|^5.4|^6.0|^7.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", From e6d298daef67748d8186da0e43a9f3bf31f9222b Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:40:04 -0500 Subject: [PATCH 07/13] Port regular expressions checks into a custom constraint --- src/Constraints/ElementMatchesRegExp.php | 68 ++++ src/MarkupAssertionsTrait.php | 89 +---- tests/Integration/AssertionsTest.php | 330 +++--------------- .../Constraints/ElementMatchesRegExpTest.php | 146 ++++++++ 4 files changed, 273 insertions(+), 360 deletions(-) create mode 100644 src/Constraints/ElementMatchesRegExp.php create mode 100644 tests/Unit/Constraints/ElementMatchesRegExpTest.php diff --git a/src/Constraints/ElementMatchesRegExp.php b/src/Constraints/ElementMatchesRegExp.php new file mode 100644 index 0000000..9a4e0e4 --- /dev/null +++ b/src/Constraints/ElementMatchesRegExp.php @@ -0,0 +1,68 @@ +selector = $selector; + $this->pattern = $pattern; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + '%s regular expression %s', + count($this->matchingElements) >= 2 ? 'match' : 'matches', + $this->exporter()->export($this->pattern) + ); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $html value or object to evaluate + * + * @return bool + */ + protected function matches($html): bool + { + $dom = new DOM($html); + + // Iterate through each matching element and look for the pattern. + foreach ($dom->getInnerHtml($this->selector) as $html) { + if (preg_match($this->pattern, $html)) { + return true; + } + } + + // Query again to get the outer elements for error reporting. + $this->matchingElements = $dom->getOuterHtml($this->selector); + + return false; + } +} diff --git a/src/MarkupAssertionsTrait.php b/src/MarkupAssertionsTrait.php index a67ec0f..f622ad0 100644 --- a/src/MarkupAssertionsTrait.php +++ b/src/MarkupAssertionsTrait.php @@ -10,12 +10,10 @@ namespace SteveGrunwell\PHPUnit_Markup_Assertions; use PHPUnit\Framework\Constraint\LogicalNot; -use PHPUnit\Framework\RiskyTestError; use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ContainsSelector; use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ElementContainsString; +use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ElementMatchesRegExp; use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\SelectorCount; -use SteveGrunwell\PHPUnit_Markup_Assertions\Exceptions\AttributeArrayException; -use Symfony\Component\DomCrawler\Crawler; trait MarkupAssertionsTrait { @@ -166,15 +164,9 @@ public function assertElementNotContains($contents, $selector = '', $markup = '' */ public function assertElementRegExp($regexp, $selector = '', $markup = '', $message = '') { - $method = method_exists($this, 'assertMatchesRegularExpression') - ? 'assertMatchesRegularExpression' - : 'assertRegExp'; // @codeCoverageIgnore + $constraint = new ElementMatchesRegExp(new Selector($selector), $regexp); - $this->$method( - $regexp, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + static::assertThat($markup, $constraint, $message); } /** @@ -191,79 +183,8 @@ public function assertElementRegExp($regexp, $selector = '', $markup = '', $mess */ public function assertElementNotRegExp($regexp, $selector = '', $markup = '', $message = '') { - $method = method_exists($this, 'assertDoesNotMatchRegularExpression') - ? 'assertDoesNotMatchRegularExpression' - : 'assertNotRegExp'; // @codeCoverageIgnore - - $this->$method( - $regexp, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); - } - - /** - * Build a new DOMDocument from the given markup, then execute a query against it. - * - * @since 1.0.0 - * - * @param string $markup The HTML for the DOMDocument. - * @param string $query The DOM selector query. - * - * @return Crawler - * - * @deprecated since 2.0.0. Use {@see DOM::query()} instead. - * This method will be removed in a future release! - * - * @codeCoverageIgnore - */ - private function executeDomQuery($markup, $query) - { - return (new DOM($markup))->query(new Selector($query)); - } - - /** - * Given an array of HTML attributes, flatten them into a XPath attribute selector. - * - * @since 1.0.0 - * - * @throws RiskyTestError When the $attributes array is empty. - * - * @param array $attributes HTML attributes and their values. - * - * @return string A XPath attribute query selector. - * - * @deprecated since 2.0.0. Use the Selector object instead. - * This method will be removed in a future release! - * - * @codeCoverageIgnore - */ - private function flattenAttributeArray(array $attributes) - { - try { - return (new Selector($attributes))->getValue(); - } catch (AttributeArrayException $e) { - throw new RiskyTestError($e->getMessage(), $e->getCode(), $e); - } - } + $constraint = new LogicalNot(new ElementMatchesRegExp(new Selector($selector), $regexp)); - /** - * Given HTML markup and a DOM selector query, collect the innerHTML of the matched selectors. - * - * @since 1.1.0 - * - * @param string $markup The HTML for the DOMDocument. - * @param string $query The DOM selector query. - * - * @return string The concatenated innerHTML of any matched selectors. - * - * @deprecated since 2.0.0. SOME OTHER ALTERNATIVE - * This method will be removed in a future release! - * - * @codeCoverageIgnore - */ - private function getInnerHtmlOfMatchedElements($markup, $query) - { - return implode(PHP_EOL, (new DOM($markup))->getInnerHtml(new Selector($query))); + static::assertThat($markup, $constraint, $message); } } diff --git a/tests/Integration/AssertionsTest.php b/tests/Integration/AssertionsTest.php index 46b5008..77e9db0 100644 --- a/tests/Integration/AssertionsTest.php +++ b/tests/Integration/AssertionsTest.php @@ -2,8 +2,6 @@ namespace Tests\Integration; -use PHPUnit\Framework\AssertionFailedError; -use PHPUnit\Framework\RiskyTestError; use PHPUnit\Framework\TestCase; use SteveGrunwell\PHPUnit_Markup_Assertions\MarkupAssertionsTrait; @@ -21,301 +19,81 @@ class MarkupAssertionsTraitTest extends TestCase use MarkupAssertionsTrait; /** - * @test - * @testdox assertContainsSelector() should find matching selectors - * @dataProvider provideSelectorVariants + * @var string */ - public function assertContainsSelector_should_find_matching_selectors($selector) - { - $this->assertContainsSelector( - $selector, - 'Example' - ); - } + private $markup = <<<'HTML' +
+

Good news, everyone!

+

According to the latest reports, + you can still test markup with PHPUnit_Markup_Assertions!

+

#TestEverything

+
+HTML; - /** - * @test - * @testdox assertContainsSelector() should pick up multiple instances of a selector - */ - public function assertContainsSelector_should_pick_up_multiple_instances() + public function testPresenceOfSelectors() { - $this->assertContainsSelector( - 'a', - 'Home | About | Contact' - ); - } + $this->assertContainsSelector('main', $this->markup); + $this->assertContainsSelector('h1', $this->markup); + $this->assertContainsSelector('a.link', $this->markup); + $this->assertContainsSelector('main p', $this->markup); + $this->assertContainsSelector('h1 + p', $this->markup); + $this->assertContainsSelector('p > a', $this->markup); + $this->assertContainsSelector('a[href$="example.com"]', $this->markup); - /** - * @test - * @testdox assertNotContainsSelector() should verify that the given selector does not exist - * @dataProvider provideSelectorVariants - */ - public function assertNotContainsSelector_should_verify_that_the_given_selector_does_not_exist($selector) - { - $this->assertNotContainsSelector( - $selector, - '

This element has little to do with the link.

' - ); - } + $this->assertNotContainsSelector('h2', $this->markup); + $this->assertNotContainsSelector('a[href="https://example.org"]', $this->markup); + $this->assertNotContainsSelector('p main', $this->markup); - /** - * @test - * @testdox assertSelectorCount() should count the instances of a selector - */ - public function assertSelectorCount_should_count_the_number_of_instances() - { - $this->assertSelectorCount( - 3, - 'li', - '
  • 1
  • 2
  • 3
' - ); - } + $this->assertSelectorCount(0, 'h2', $this->markup); + $this->assertSelectorCount(1, 'h1', $this->markup); + $this->assertSelectorCount(2, 'p', $this->markup); - /** - * @test - * @testdox assertHasElementWithAttributes() should find an element with the given attributes - */ - public function assertHasElementWithAttributes_should_find_elements_with_matching_attributes() - { - $this->assertHasElementWithAttributes( - [ - 'type' => 'email', - 'value' => 'test@example.com', - ], - '
' - ); - } + $this->assertHasElementWithAttributes(['href' => 'https://example.com'], $this->markup); - /** - * @test - * @testdox assertHasElementWithAttributes() should be able to parse spaces in attribute values - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/13 - */ - public function assertHasElementWithAttributes_should_be_able_to_handle_spaces() - { - $this->assertHasElementWithAttributes( - [ - 'data-attr' => 'foo bar baz', - ], - '
Contents
' - ); - } - - /** - * @test - * @testdox assertNotHasElementWithAttributes() should ensure no element has the provided attributes - */ - public function assertNotHasElementWithAttributes_should_find_no_elements_with_matching_attributes() - { $this->assertNotHasElementWithAttributes( - [ - 'type' => 'email', - 'value' => 'test@example.com', - ], - '
' - ); - } - - /** - * @test - * @testdox assertElementContains() should be able to search for a selector - */ - public function assertElementContains_can_match_a_selector() - { - $this->assertElementContains( - 'ipsum', - '#main', - '
Lorem ipsum
Lorem ipsum
' - ); - } - - /** - * @test - * @testdox assertElementContains() should be able to chain multiple selectors - */ - public function assertElementContains_can_chain_multiple_selectors() - { - $this->assertElementContains( - 'ipsum', - '#main .foo', - '
Lorem ipsum
' - ); - } - - /** - * @test - * @testdox assertElementContains() should scope text to the selected element - */ - public function assertElementContains_should_scope_matches_to_selector() - { - $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('The #main div does not contain the string "ipsum".'); - - $this->assertElementContains( - 'ipsum', - '#main', - '
Lorem ipsum
Foo bar baz
', - 'The #main div does not contain the string "ipsum".' + ['href' => 'https://example.org'], + $this->markup, + 'URL uses .com, not .org.' ); } - /** - * @test - * @testdox assertElementContains() should handle various character sets - * @dataProvider provideGreetingsInDifferentLanguages - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 - */ - public function assertElementContains_should_handle_various_character_sets($greeting) + public function testMatchingContentsOfSelectors() { - $this->assertElementContains( - $greeting, - 'h1', - sprintf('

%s

', $greeting) - ); - } + $this->assertElementContains('Good news', 'main', $this->markup); + $this->assertElementContains('Good news', 'h1', $this->markup); + $this->assertElementContains('#TestEverything', 'p', $this->markup); + $this->assertElementContains('class="link"', 'p', $this->markup); + $this->assertElementContains('#TestEverything', 'main *:last-child', $this->markup); - /** - * @test - * @testdox assertElementNotContains() should be able to search for a selector - */ - public function assertElementNotContains_can_match_a_selector() - { + $this->assertElementNotContains('good news', 'h1', $this->markup, 'Case-sensitive by default.'); $this->assertElementNotContains( - 'ipsum', - '#main', - '
Foo bar baz
Some string
' + '#TestEverything', + 'p:first-child', + $this->markup, + '#TestEverything is in the second paragraph' ); - } - - /** - * @test - * @testdox assertElementNotContains() should handle various character sets - * @dataProvider provideGreetingsInDifferentLanguages - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 - */ - public function assertElementNotContains_should_handle_various_character_sets($greeting) - { $this->assertElementNotContains( - $greeting, - 'h1', - sprintf('

Translation

%s

', $greeting) + 'class="link"', + 'a', + $this->markup, + 'The class is part of the outer, not inner HTML' ); - } - /** - * @test - * @testdox assertElementRegExp() should use regular expression matching - */ - public function assertElementRegExp_should_use_regular_expression_matching() - { - $this->assertElementRegExp( - '/[A-Z0-9-]+/', - '#main', - '
Lorem ipsum
ABC123
' - ); - } + $this->assertElementRegExp('/\w+ news/', 'h1', $this->markup); + $this->assertElementRegExp('/GOOD NEWS/i', 'h1', $this->markup); + $this->assertElementRegExp('/latest reports/', 'p > a', $this->markup); - /** - * @test - * @testdox assertElementRegExp() should be able to search for nested contents - */ - public function assertElementRegExp_should_be_able_to_match_nested_contents() - { - $this->assertElementRegExp( - '/[A-Z]+/', - '#main', - '
Lorem ipsum
ABC
' + $this->assertElementNotRegExp( + '/\w+ news/', + 'p', + $this->markup, + 'This text is in the heading, not the paragraph.' ); - } - - /** - * @test - * @testdox assertElementNotRegExp() should use regular expression matching - */ - public function testAssertElementNotRegExp() - { $this->assertElementNotRegExp( - '/[0-9-]+/', - '#main', - '
Foo bar baz
ABC
' + '/#TESTEVERYTHING/', + 'p', + $this->markup, + 'No case-insensitive flag' ); } - - /** - * @test - * @testdox getInnerHtmlOfMatchedElements() should retrieve the inner HTML - * @dataProvider provideInnerHtml - */ - public function getInnerHtmlOfMatchedElements_should_retrieve_the_inner_HTML($markup, $selector, $expected) - { - $method = new \ReflectionMethod($this, 'getInnerHtmlOfMatchedElements'); - $method->setAccessible(true); - - $this->assertEquals($expected, $method->invoke($this, $markup, $selector)); - } - - - - /** - * Data provider for testGetInnerHtmlOfMatchedElements(). - * - * @return array> - */ - public function provideInnerHtml() - { - return [ - 'A single match' => [ - 'Foo bar baz', - 'body', - 'Foo bar baz', - ], - 'Multiple matching elements' => [ - '
  • Foo
  • Bar
  • Baz
  • ', - 'li', - 'Foo' . PHP_EOL . 'Bar' . PHP_EOL . 'Baz', - ], - 'Nested elements' => [ - '

    Example site

    ', - 'h1', - 'Example site', - ], - ]; - } - - /** - * Data provider for testAssertContainsSelector(). - * - * @return array> - */ - public function provideSelectorVariants() - { - return [ - 'Simple tag name' => ['a'], - 'Class name' => ['.link'], - 'Multiple class names' => ['.link.another-class'], - 'Element ID' => ['#my-link'], - 'Tag name with class' => ['a.link'], - 'Tag name with ID' => ['a#my-link'], - 'Tag with href attribute' => ['a[href="https://example.com"]'], - ]; - } - - /** - * Provide a list of strings in various language. - * - * @return array> - */ - public function provideGreetingsInDifferentLanguages() - { - return [ - 'Arabic' => ['مرحبا!'], - 'Chinese' => ['你好'], - 'English' => ['Hello'], - 'Hebrew' => ['שלום'], - 'Japanese' => ['こんにちは'], - 'Korean' => ['안녕하십니까'], - 'Punjabi' => ['ਸਤ ਸ੍ਰੀ ਅਕਾਲ'], - 'Ukrainian' => ['Привіт'], - ]; - } } diff --git a/tests/Unit/Constraints/ElementMatchesRegExpTest.php b/tests/Unit/Constraints/ElementMatchesRegExpTest.php new file mode 100644 index 0000000..3d88125 --- /dev/null +++ b/tests/Unit/Constraints/ElementMatchesRegExpTest.php @@ -0,0 +1,146 @@ +Title

    12345

    '; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_respect_flags() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/[A-Z]+/i'); + $html = '

    Title

    123hello456

    '; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_if_no_match_is_found() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/[a-z]+/'); + $html = '

    Title

    12345

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + '"12345" does not match pattern /[a-z]/' + ); + } + + /** + * @test + */ + public function it_should_test_against_the_inner_contents_of_the_found_nodes() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/class/'); + $html = '

    First

    Second

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'The string "class" does not appear in either paragraph, and thus should not be matched.' + ); + } + + /** + * @test + */ + public function it_should_scope_queries_to_the_selector() + { + $constraint = new ElementMatchesRegExp(new Selector('h1'), '/\d+/'); + $html = '

    Title

    12345

    '; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $html = '

    Some other string

    '; + $expected = <<<'MSG' +Failed asserting that element matching selector 'p' matches regular expression '/some\sstring/'. +Matching element: +[ +

    Some other string

    +] +MSG; + + try { + (new ElementMatchesRegExp(new Selector('p'), '/some\sstring/'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_include_all_relevant_matches_in_error_messages() + { + $selector = new Selector('p'); + $html = '

    Some other string

    Yet another string

    '; + $expected = <<<'MSG' +Failed asserting that any elements matching selector 'p' match regular expression '/some\sstring/'. +Matching elements: +[ +

    Some other string

    +

    Yet another string

    +] +MSG; + + try { + (new ElementMatchesRegExp($selector, '/some\sstring/'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_provide_a_simple_error_message_if_no_selector_matches_are_found() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = "Failed asserting that any elements match selector 'h1'."; + + try { + (new ElementMatchesRegExp(new Selector('h1'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } +} From 26a085d72623e6c931ccfb99515ebac522d05553 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:41:21 -0500 Subject: [PATCH 08/13] Tighten up the constraints and take advantage of strict(er) types --- src/Constraints/ContainsSelector.php | 12 +- src/Constraints/ElementContainsString.php | 84 +++++++++++--- src/Constraints/SelectorCount.php | 9 +- .../Unit/Constraints/ContainsSelectorTest.php | 8 +- .../Constraints/ElementContainsStringTest.php | 108 ++++++++++++++++-- tests/Unit/Constraints/SelectorCountTest.php | 23 +++- 6 files changed, 206 insertions(+), 38 deletions(-) diff --git a/src/Constraints/ContainsSelector.php b/src/Constraints/ContainsSelector.php index eb20858..0cd9348 100644 --- a/src/Constraints/ContainsSelector.php +++ b/src/Constraints/ContainsSelector.php @@ -6,6 +6,9 @@ use SteveGrunwell\PHPUnit_Markup_Assertions\DOM; use SteveGrunwell\PHPUnit_Markup_Assertions\Selector; +/** + * Evaluate whether or not markup contains at least one instance of the given selector. + */ class ContainsSelector extends Constraint { /** @@ -32,16 +35,15 @@ public function toString(): string } /** - * Evaluates the constraint for parameter $other. Returns true if the - * constraint is met, false otherwise. + * {@inheritDoc} * - * @param mixed $other value or object to evaluate + * @param mixed $html The HTML to evaluate. * * @return bool */ - protected function matches($value): bool + protected function matches($html): bool { - $dom = new DOM($value); + $dom = new DOM($html); return $dom->countInstancesOfSelector($this->selector) > 0; } diff --git a/src/Constraints/ElementContainsString.php b/src/Constraints/ElementContainsString.php index 74ddb8d..658c2f9 100644 --- a/src/Constraints/ElementContainsString.php +++ b/src/Constraints/ElementContainsString.php @@ -6,8 +6,23 @@ use SteveGrunwell\PHPUnit_Markup_Assertions\DOM; use SteveGrunwell\PHPUnit_Markup_Assertions\Selector; +/** + * Evaluate whether or not the element(s) matching the given selector contain a given string. + */ class ElementContainsString extends Constraint { + /** + * A cache of matches that we have checked against. + * + * @var array + */ + protected $matchingElements = []; + + /** + * @var Selector + */ + protected $selector; + /** * @var bool */ @@ -18,11 +33,6 @@ class ElementContainsString extends Constraint */ private $needle; - /** - * @var Selector - */ - private $selector; - /** * @param Selector $selector The query selector. * @param string $needle The string to search for within the matching element(s). @@ -44,39 +54,80 @@ public function __construct(Selector $selector, $needle, $ignore_case = false) public function toString(): string { return sprintf( - 'contains string %s', + '%s string %s', + count($this->matchingElements) >= 2 ? 'contain' : 'contains', $this->exporter()->export($this->needle) ); } /** - * {@inheritDoc} + * Return additional failure description where needed. + * + * The function can be overridden to provide additional failure + * information like a diff * * @param mixed $other evaluated value or object + */ + protected function additionalFailureDescription($other): string + { + if (empty($this->matchingElements)) { + return ''; + } + + return sprintf( + "%s\n%s", + count($this->matchingElements) >= 2 ? 'Matching elements:' : 'Matching element:', + $this->exportMatchesArray($this->matchingElements) + ); + } + + /** + * Export an array of DOM matches for a selector. + * + * @param array $matches * * @return string */ - protected function failureDescription($other): string + protected function exportMatchesArray(array $matches): string { + return '[' . PHP_EOL . ' ' . implode(PHP_EOL . ' ', $matches) . PHP_EOL . ']'; + } + + /** + * {@inheritDoc} + * + * @param mixed $html The evaluated markup. Will not actually be used, instead replaced with + * {@see $this->matches}. + * + * @return string + */ + protected function failureDescription($html): string + { + if (empty($this->matchingElements)) { + return "any elements match selector '{$this->selector->getValue()}'"; + } + + $label = count($this->matchingElements) >= 2 + ? 'any elements matching selector %s %s' + : 'element matching selector %s %s'; + return sprintf( - 'element with selector %s in %s %s', + $label, $this->exporter()->export($this->selector->getValue()), - $this->exporter()->export($other), $this->toString() ); } /** - * Evaluates the constraint for parameter $other. Returns true if the - * constraint is met, false otherwise. + * {@inheritDoc} * - * @param mixed $other value or object to evaluate + * @param mixed $html The HTML to match against. * * @return bool */ - protected function matches($value): bool + protected function matches($html): bool { - $dom = new DOM($value); + $dom = new DOM($html); $fn = $this->ignore_case ? 'stripos' : 'strpos'; // Iterate through each matching element and look for the text. @@ -86,6 +137,9 @@ protected function matches($value): bool } } + // Query again to get the outer elements for error reporting. + $this->matchingElements = $dom->getOuterHtml($this->selector); + return false; } } diff --git a/src/Constraints/SelectorCount.php b/src/Constraints/SelectorCount.php index f0495ac..013ff87 100644 --- a/src/Constraints/SelectorCount.php +++ b/src/Constraints/SelectorCount.php @@ -6,6 +6,9 @@ use SteveGrunwell\PHPUnit_Markup_Assertions\DOM; use SteveGrunwell\PHPUnit_Markup_Assertions\Selector; +/** + * Evaluate how many times a selector appears within some markup. + */ class SelectorCount extends Constraint { /** @@ -22,7 +25,7 @@ class SelectorCount extends Constraint * @param Selector $selector The query selector. * @param int $count The expected number of matches. */ - public function __construct(Selector $selector, $count) + public function __construct(Selector $selector, int $count) { $this->selector = $selector; $this->count = $count; @@ -50,9 +53,9 @@ public function toString(): string * * @return bool */ - protected function matches($value): bool + protected function matches($html): bool { - $dom = new DOM($value); + $dom = new DOM($html); return $dom->countInstancesOfSelector($this->selector) === $this->count; } diff --git a/tests/Unit/Constraints/ContainsSelectorTest.php b/tests/Unit/Constraints/ContainsSelectorTest.php index c9bd4f3..29103cb 100644 --- a/tests/Unit/Constraints/ContainsSelectorTest.php +++ b/tests/Unit/Constraints/ContainsSelectorTest.php @@ -10,6 +10,8 @@ * @testdox Constraints\ContainsSelector * * @covers SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ContainsSelector + * + * @group Constraints */ class ContainsSelectorTest extends TestCase { @@ -42,12 +44,12 @@ public function it_should_not_find_unmatched_selectors_in_content($selector) */ public function it_should_fail_with_a_useful_error_message() { - $selector = new Selector('p.body'); + $selector = new Selector('p'); $html = '

    Some Title

    '; try { (new ContainsSelector($selector))->evaluate($html); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->assertSame( "Failed asserting that '{$html}' contains selector '{$selector}'.", $e->getMessage() @@ -61,7 +63,7 @@ public function it_should_fail_with_a_useful_error_message() /** * Data provider for testAssertContainsSelector(). * - * @return Iterable> + * @return iterable> */ public function provideSelectorVariants() { diff --git a/tests/Unit/Constraints/ElementContainsStringTest.php b/tests/Unit/Constraints/ElementContainsStringTest.php index 23ff433..4a050a2 100644 --- a/tests/Unit/Constraints/ElementContainsStringTest.php +++ b/tests/Unit/Constraints/ElementContainsStringTest.php @@ -10,6 +10,8 @@ * @testdox Constraints\ElementContainsString * * @covers SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ElementContainsString + * + * @group Constraints */ class ElementContainsStringTest extends TestCase { @@ -63,24 +65,116 @@ public function it_should_fail_if_no_match_is_found() $this->assertFalse($constraint->evaluate($html, '', true)); } + /** + * @test + * + * @dataProvider provideGreetingsInDifferentLanguages + * + * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 + */ + public function it_should_be_able_to_handle_various_character_sets($greeting) + { + $constraint = new ElementContainsString(new Selector('h1'), $greeting); + $html = sprintf('

    %s

    ', $greeting); + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_test_against_the_inner_contents_of_the_found_nodes() + { + $constraint = new ElementContainsString(new Selector('p'), 'class'); + $html = '

    First

    Second

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'The string "class" does not appear in either paragraph, and thus should not be matched.' + ); + } + /** * @test */ public function it_should_fail_with_a_useful_error_message() { - $selector = new Selector('p.body'); $html = '

    Some other string

    '; + $expected = <<<'MSG' +Failed asserting that element matching selector 'p' contains string 'some string'. +Matching element: +[ +

    Some other string

    +] +MSG; + + try { + (new ElementContainsString(new Selector('p'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_include_all_relevant_matches_in_error_messages() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = <<<'MSG' +Failed asserting that any elements matching selector 'p' contain string 'some string'. +Matching elements: +[ +

    Some other string

    +

    Yet another string

    +] +MSG; + + try { + (new ElementContainsString(new Selector('p'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_provide_a_simple_error_message_if_no_selector_matches_are_found() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = "Failed asserting that any elements match selector 'h1'."; try { - (new ElementContainsString($selector, 'some string'))->evaluate($html); - } catch (\Exception $e) { - $this->assertSame( - "Failed asserting that element with selector '{$selector}' in '{$html}' contains string 'some string'.", - $e->getMessage() - ); + (new ElementContainsString(new Selector('h1'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); return; } $this->fail('Did not receive the expected error message.'); } + + /** + * Provide a list of strings in various language. + * + * @return Iterable> + */ + public function provideGreetingsInDifferentLanguages() + { + yield 'Arabic' => ['مرحبا!']; + yield 'Chinese' => ['你好']; + yield 'English' => ['Hello']; + yield 'Hebrew' => ['שלום']; + yield 'Japanese' => ['こんにちは']; + yield 'Korean' => ['안녕하십니까']; + yield 'Punjabi' => ['ਸਤ ਸ੍ਰੀ ਅਕਾਲ']; + yield 'Ukrainian' => ['Привіт']; + } } diff --git a/tests/Unit/Constraints/SelectorCountTest.php b/tests/Unit/Constraints/SelectorCountTest.php index d28b61d..4edce40 100644 --- a/tests/Unit/Constraints/SelectorCountTest.php +++ b/tests/Unit/Constraints/SelectorCountTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit\Constraints; use PHPUnit\Framework\TestCase; -use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\ContainsSelector; use SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\SelectorCount; use SteveGrunwell\PHPUnit_Markup_Assertions\Selector; @@ -11,6 +10,8 @@ * @testdox Constraints\SelectorCount * * @covers SteveGrunwell\PHPUnit_Markup_Assertions\Constraints\SelectorCount + * + * @group Constraints */ class SelectorCountTest extends TestCase { @@ -19,15 +20,27 @@ class SelectorCountTest extends TestCase * @dataProvider provideMarkupVariants */ public function it_should_determine_if_the_expected_number_of_instances_are_present( - $markup, + string $markup, Selector $selector, - $expected + int $expected ) { $constraint = new SelectorCount($selector, $expected); $this->assertTrue($constraint->evaluate($markup, '', true)); } + /** + * @test + * @dataProvider provideMarkupVariants + */ + public function it_should_fail_if_it_contains_a_different_number_of_matches() + { + $markup = '

    This is the only paragraph

    '; + + $this->assertFalse((new SelectorCount(new Selector('p'), 0))->evaluate($markup, '', true)); + $this->assertFalse((new SelectorCount(new Selector('p'), 2))->evaluate($markup, '', true)); + } + /** * @test */ @@ -38,7 +51,7 @@ public function it_should_fail_with_a_useful_error_message() try { (new SelectorCount($selector, 5))->evaluate($html); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->assertSame( "Failed asserting that '{$html}' contains 5 instance(s) of selector '{$selector}'.", $e->getMessage() @@ -52,7 +65,7 @@ public function it_should_fail_with_a_useful_error_message() /** * Data provider for testAssertContainsSelector(). * - * @return Iterable + * @return iterable */ public function provideMarkupVariants() { From fdf46f37e5eb2e0ec1a4b49d150506153e2ae77d Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:44:09 -0500 Subject: [PATCH 09/13] Add a getOuterHtml() method to DOM --- src/DOM.php | 15 +++++++++++ tests/Unit/DOMTest.php | 57 ++++++++++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/DOM.php b/src/DOM.php index 5378bcf..69657e0 100644 --- a/src/DOM.php +++ b/src/DOM.php @@ -52,6 +52,21 @@ public function getInnerHtml(Selector $selector) }); } + /** + * Retrieve the inner contents of elements matching the given selector. + * + * @param Selector $selector The query selector. + * + * @return array The inner contents of the matched selector. Each match is a separate + * value in the array. + */ + public function getOuterHtml(Selector $selector) + { + return $this->query($selector)->each(function ($element) { + return $element->outerHtml(); + }); + } + /** * @param Selector $selector The query selector. * diff --git a/tests/Unit/DOMTest.php b/tests/Unit/DOMTest.php index c741e6e..2bace0a 100644 --- a/tests/Unit/DOMTest.php +++ b/tests/Unit/DOMTest.php @@ -34,12 +34,12 @@ public function it_should_be_able_to_count_selectors($markup, $expected) public function getInnerHtml_should_retrieve_the_inner_HTML_for_each_matching_element() { $markup = <<<'HTML' -
      -
    • The strong element
    • -
    • The em element
    • -
    • The kbd element
    • -
    - HTML; +
      +
    • The strong element
    • +
    • The em element
    • +
    • The kbd element
    • +
    +HTML; $dom = new DOM($markup); $this->assertSame( @@ -48,7 +48,7 @@ public function getInnerHtml_should_retrieve_the_inner_HTML_for_each_matching_el 'The em element', 'The kbd element', ], - $dom->getInnerHtml(new Selector('li')), + $dom->getInnerHtml(new Selector('li')) ); } @@ -63,6 +63,42 @@ public function getInnerHtml_should_return_an_empty_array_if_there_are_no_matche $this->assertEmpty($dom->getInnerHtml(new Selector('h2'))); } + /** + * @test + * @testdox getOuterHtml() should retrieve the outer HTML for each matching element. + */ + public function getOuterHtml_should_retrieve_the_outer_HTML_for_each_matching_element() + { + $markup = <<<'HTML' +
      +
    • The strong element
    • +
    • The em element
    • +
    • The kbd element
    • +
    +HTML; + $dom = new DOM($markup); + + $this->assertSame( + [ + '
  • The strong element
  • ', + '
  • The em element
  • ', + '
  • The kbd element
  • ', + ], + $dom->getOuterHtml(new Selector('li')) + ); + } + + /** + * @test + * @testdox getOuterHtml() should return an empty array if there are no matches + */ + public function getOuterHtml_should_return_an_empty_array_if_there_are_no_matches() + { + $dom = new DOM('

    A title

    '); + + $this->assertEmpty($dom->getOuterHtml(new Selector('h2'))); + } + /** * @test * @testdox query() should throw a SelectorException if the selector is invalid @@ -100,12 +136,7 @@ public function provideMarkupWithInnerClass() ]; yield 'Two matches' => [ - <<<'HTML' -
    -
    One
    -
    Two
    -
    - HTML, + '
    One
    Two
    ', 2 ]; } From ee498a8fe5b521082e077c0f11a9dbab911c86f1 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:45:10 -0500 Subject: [PATCH 10/13] Embrace strict(er) types, ensure that #13 is covered by Selector --- src/Constraints/ElementContainsString.php | 2 + src/Constraints/SelectorCount.php | 2 +- src/MarkupAssertionsTrait.php | 120 +++++++++++------- src/Selector.php | 6 +- .../Unit/Constraints/ContainsSelectorTest.php | 4 +- .../Constraints/ElementContainsStringTest.php | 4 +- tests/Unit/DOMTest.php | 6 +- tests/Unit/SelectorTest.php | 25 +++- 8 files changed, 111 insertions(+), 58 deletions(-) diff --git a/src/Constraints/ElementContainsString.php b/src/Constraints/ElementContainsString.php index 658c2f9..ce6e890 100644 --- a/src/Constraints/ElementContainsString.php +++ b/src/Constraints/ElementContainsString.php @@ -90,6 +90,8 @@ protected function additionalFailureDescription($other): string */ protected function exportMatchesArray(array $matches): string { + $matches = array_map('trim', $matches); + return '[' . PHP_EOL . ' ' . implode(PHP_EOL . ' ', $matches) . PHP_EOL . ']'; } diff --git a/src/Constraints/SelectorCount.php b/src/Constraints/SelectorCount.php index 013ff87..cf3e99e 100644 --- a/src/Constraints/SelectorCount.php +++ b/src/Constraints/SelectorCount.php @@ -49,7 +49,7 @@ public function toString(): string * Evaluates the constraint for parameter $other. Returns true if the * constraint is met, false otherwise. * - * @param mixed $other value or object to evaluate + * @param mixed $html value or object to evaluate * * @return bool */ diff --git a/src/MarkupAssertionsTrait.php b/src/MarkupAssertionsTrait.php index f622ad0..f505253 100644 --- a/src/MarkupAssertionsTrait.php +++ b/src/MarkupAssertionsTrait.php @@ -22,14 +22,17 @@ trait MarkupAssertionsTrait * * @since 1.0.0 * - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertContainsSelector($selector, $markup = '', $message = '') - { + public function assertContainsSelector( + $selector, + string $markup = '', + string $message = '' + ) { $constraint = new ContainsSelector(new Selector($selector)); static::assertThat($markup, $constraint, $message); @@ -40,14 +43,17 @@ public function assertContainsSelector($selector, $markup = '', $message = '') * * @since 1.0.0 * - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertNotContainsSelector($selector, $markup = '', $message = '') - { + public function assertNotContainsSelector( + $selector, + string $markup = '', + string $message = '' + ) { $constraint = new LogicalNot(new ContainsSelector(new Selector($selector))); static::assertThat($markup, $constraint, $message); @@ -58,15 +64,19 @@ public function assertNotContainsSelector($selector, $markup = '', $message = '' * * @since 1.0.0 * - * @param int $count The number of matching elements expected. - * @param string $selector A query selector for the element to find. - * @param string $markup The markup to run the assertion against. - * @param string $message A message to display if the assertion fails. + * @param int $count The number of matching elements expected. + * @param string|array $selector A query selector to search for. + * @param string $markup The markup to run the assertion against. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertSelectorCount($count, $selector, $markup = '', $message = '') - { + public function assertSelectorCount( + int $count, + $selector, + string $markup = '', + string $message = '' + ) { $constraint = new SelectorCount(new Selector($selector), $count); static::assertThat($markup, $constraint, $message); @@ -85,8 +95,11 @@ public function assertSelectorCount($count, $selector, $markup = '', $message = * * @return void */ - public function assertHasElementWithAttributes($attributes = [], $markup = '', $message = '') - { + public function assertHasElementWithAttributes( + array $attributes = [], + string $markup = '', + string $message = '' + ) { $constraint = new ContainsSelector(new Selector($attributes)); static::assertThat($markup, $constraint, $message); @@ -105,8 +118,11 @@ public function assertHasElementWithAttributes($attributes = [], $markup = '', $ * * @return void */ - public function assertNotHasElementWithAttributes($attributes = [], $markup = '', $message = '') - { + public function assertNotHasElementWithAttributes( + $attributes = [], + $markup = '', + $message = '' + ) { $constraint = new LogicalNot(new ContainsSelector(new Selector($attributes))); static::assertThat($markup, $constraint, $message); @@ -117,15 +133,19 @@ public function assertNotHasElementWithAttributes($attributes = [], $markup = '' * * @since 1.1.0 * - * @param string $contents The string to look for within the DOM node's contents. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $contents The string to look for within the DOM node's contents. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementContains($contents, $selector = '', $markup = '', $message = '') - { + public function assertElementContains( + string $contents, + $selector = '', + string $markup = '', + string $message = '' + ) { $constraint = new ElementContainsString(new Selector($selector), $contents); static::assertThat($markup, $constraint, $message); @@ -136,15 +156,19 @@ public function assertElementContains($contents, $selector = '', $markup = '', $ * * @since 1.1.0 * - * @param string $contents The string to look for within the DOM node's contents. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $contents The string to look for within the DOM node's contents. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementNotContains($contents, $selector = '', $markup = '', $message = '') - { + public function assertElementNotContains( + string $contents, + $selector = '', + string $markup = '', + string $message = '' + ) { $constraint = new LogicalNot(new ElementContainsString(new Selector($selector), $contents)); static::assertThat($markup, $constraint, $message); @@ -155,15 +179,19 @@ public function assertElementNotContains($contents, $selector = '', $markup = '' * * @since 1.1.0 * - * @param string $regexp The regular expression pattern to look for within the DOM node. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $regexp The regular expression pattern to look for within the DOM node. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementRegExp($regexp, $selector = '', $markup = '', $message = '') - { + public function assertElementRegExp( + string $regexp, + $selector = '', + string $markup = '', + string $message = '' + ) { $constraint = new ElementMatchesRegExp(new Selector($selector), $regexp); static::assertThat($markup, $constraint, $message); @@ -174,15 +202,19 @@ public function assertElementRegExp($regexp, $selector = '', $markup = '', $mess * * @since 1.1.0 * - * @param string $regexp The regular expression pattern to look for within the DOM node. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $regexp The regular expression pattern to look for within the DOM node. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementNotRegExp($regexp, $selector = '', $markup = '', $message = '') - { + public function assertElementNotRegExp( + string $regexp, + $selector = '', + string $markup = '', + string $message = '' + ) { $constraint = new LogicalNot(new ElementMatchesRegExp(new Selector($selector), $regexp)); static::assertThat($markup, $constraint, $message); diff --git a/src/Selector.php b/src/Selector.php index 0b0f671..f4a58c4 100644 --- a/src/Selector.php +++ b/src/Selector.php @@ -38,7 +38,7 @@ public function __construct($selector) * * @return string */ - public function __toString() + public function __toString(): string { return $this->getValue(); } @@ -48,7 +48,7 @@ public function __toString() * * @return string */ - public function getValue() + public function getValue(): string { return $this->selector; } @@ -61,7 +61,7 @@ public function getValue() * * @return string The flattened attribute array. */ - private function attributeArrayToString($attributes) + private function attributeArrayToString(array $attributes): string { if (empty($attributes)) { throw new AttributeArrayException('Attributes array is empty.'); diff --git a/tests/Unit/Constraints/ContainsSelectorTest.php b/tests/Unit/Constraints/ContainsSelectorTest.php index 29103cb..bba4687 100644 --- a/tests/Unit/Constraints/ContainsSelectorTest.php +++ b/tests/Unit/Constraints/ContainsSelectorTest.php @@ -19,7 +19,7 @@ class ContainsSelectorTest extends TestCase * @test * @dataProvider provideSelectorVariants */ - public function it_should_find_matching_selectors_in_content($selector) + public function it_should_find_matching_selectors_in_content(string $selector) { $constraint = new ContainsSelector(new Selector($selector)); $html = 'Example'; @@ -31,7 +31,7 @@ public function it_should_find_matching_selectors_in_content($selector) * @test * @dataProvider provideSelectorVariants */ - public function it_should_not_find_unmatched_selectors_in_content($selector) + public function it_should_not_find_unmatched_selectors_in_content(string $selector) { $constraint = new ContainsSelector(new Selector($selector)); $html = '

    This element has little to do with the link.

    '; diff --git a/tests/Unit/Constraints/ElementContainsStringTest.php b/tests/Unit/Constraints/ElementContainsStringTest.php index 4a050a2..3354c22 100644 --- a/tests/Unit/Constraints/ElementContainsStringTest.php +++ b/tests/Unit/Constraints/ElementContainsStringTest.php @@ -72,7 +72,7 @@ public function it_should_fail_if_no_match_is_found() * * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 */ - public function it_should_be_able_to_handle_various_character_sets($greeting) + public function it_should_be_able_to_handle_various_character_sets(string $greeting) { $constraint = new ElementContainsString(new Selector('h1'), $greeting); $html = sprintf('

    %s

    ', $greeting); @@ -164,7 +164,7 @@ public function it_should_provide_a_simple_error_message_if_no_selector_matches_ /** * Provide a list of strings in various language. * - * @return Iterable> + * @return iterable> */ public function provideGreetingsInDifferentLanguages() { diff --git a/tests/Unit/DOMTest.php b/tests/Unit/DOMTest.php index 2bace0a..f077205 100644 --- a/tests/Unit/DOMTest.php +++ b/tests/Unit/DOMTest.php @@ -19,7 +19,7 @@ final class DOMTest extends TestCase * * @dataProvider provideMarkupWithInnerClass */ - public function it_should_be_able_to_count_selectors($markup, $expected) + public function it_should_be_able_to_count_selectors(string $markup, int $expected) { $dom = new DOM($markup); $selector = new Selector('.inner'); @@ -63,7 +63,7 @@ public function getInnerHtml_should_return_an_empty_array_if_there_are_no_matche $this->assertEmpty($dom->getInnerHtml(new Selector('h2'))); } - /** + /** * @test * @testdox getOuterHtml() should retrieve the outer HTML for each matching element. */ @@ -121,7 +121,7 @@ public function query_should_throw_a_SelectorException_if_the_selector_is_invali /** * Return test cases with varying numbers of .inner elements. * - * @return Iterable + * @return iterable */ public function provideMarkupWithInnerClass() { diff --git a/tests/Unit/SelectorTest.php b/tests/Unit/SelectorTest.php index 5ce0fc9..b87f2f8 100644 --- a/tests/Unit/SelectorTest.php +++ b/tests/Unit/SelectorTest.php @@ -26,15 +26,34 @@ public function it_should_accept_string_selectors() /** * @test * + * @param array $attributes + * @param string $expected + * * @dataProvider provideAttributes */ - public function it_should_automatically_convert_attribute_arrays_to_strings($attributes, $expected) - { + public function it_should_automatically_convert_attribute_arrays_to_strings( + array $attributes, + string $expected + ) { $selector = new Selector($attributes); $this->assertSame($expected, $selector->getValue()); } + /** + * @test + * + * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/13 + */ + public function it_should_be_able_to_handle_spaces_in_attribute_values() + { + $selector = new Selector([ + 'data-attr' => 'foo bar baz', + ]); + + $this->assertSame('*[data-attr="foo bar baz"]', $selector->getValue()); + } + /** * @test */ @@ -58,7 +77,7 @@ public function it_should_be_able_to_be_cast_to_a_string() /** * Data provider for testFlattenAttributeArray(). * - * @return Iterable{array, string} The attribute array and teh expected string. + * @return iterable, string}> The attribute array and the expected string. */ public function provideAttributes() { From f85fca47b09b77612f066a3b9bac7ea46c03bacb Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Mon, 11 Dec 2023 20:28:51 -0500 Subject: [PATCH 11/13] Force the PHPStan job to install a version of PHPUnit --- .github/workflows/static-code-analysis.yml | 3 +++ phpstan.neon.dist | 2 ++ src/Exceptions/SelectorException.php | 3 +++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/static-code-analysis.yml b/.github/workflows/static-code-analysis.yml index 38397a9..36dda64 100644 --- a/.github/workflows/static-code-analysis.yml +++ b/.github/workflows/static-code-analysis.yml @@ -19,5 +19,8 @@ jobs: - name: Install Composer dependencies uses: ramsey/composer-install@v2 + - name: Install PHPUnit + run: composer test -- --version + - name: Run PHPStan run: composer static-analysis diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b980ecc..c6e358c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,3 +5,5 @@ parameters: - tests excludePaths: - tests/coverage + ignoreErrors: + - '#Method Tests\\.+ has no return type specified.#' diff --git a/src/Exceptions/SelectorException.php b/src/Exceptions/SelectorException.php index 40cdebe..4552bc3 100644 --- a/src/Exceptions/SelectorException.php +++ b/src/Exceptions/SelectorException.php @@ -2,6 +2,9 @@ namespace SteveGrunwell\PHPUnit_Markup_Assertions\Exceptions; +/** + * Base exception for selector-related issues. + */ class SelectorException extends \InvalidArgumentException { } From f316bc31e5d6350e726c0e60cf251b24c2535511 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Mon, 18 Dec 2023 19:35:07 -0500 Subject: [PATCH 12/13] Add a fallback method for getOuterHtml() on PHP 7.0 --- src/DOM.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/DOM.php b/src/DOM.php index 69657e0..52888c2 100644 --- a/src/DOM.php +++ b/src/DOM.php @@ -63,7 +63,20 @@ public function getInnerHtml(Selector $selector) public function getOuterHtml(Selector $selector) { return $this->query($selector)->each(function ($element) { - return $element->outerHtml(); + + /* + * The outerHtml() method was added in Symfony 4.4, which supports PHP 7.1.3+. + * + * @link https://symfony.com/blog/new-in-symfony-4-4-new-domcrawler-methods + */ + if (method_exists($element, 'outerHtml')) { + return $element->outerHtml(); + } + + // Fallback for PHP 7.0. + $node = $element->getNode(0); + + return $node->ownerDocument->saveHtml($node); }); } From 6ad918bb91e31b982a5cd9fb9a774d2ceb7e7345 Mon Sep 17 00:00:00 2001 From: Steve Grunwell <233836+stevegrunwell@users.noreply.github.com> Date: Mon, 18 Dec 2023 19:59:35 -0500 Subject: [PATCH 13/13] Ensure that constraints' parent constructors are called, add logic for handling differences in exporting values --- src/Constraints/ContainsSelector.php | 6 +++- src/Constraints/ElementContainsString.php | 8 +++-- src/Constraints/ElementMatchesRegExp.php | 4 ++- src/Constraints/ExporterTrait.php | 38 +++++++++++++++++++++++ src/Constraints/SelectorCount.php | 6 +++- 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/Constraints/ExporterTrait.php diff --git a/src/Constraints/ContainsSelector.php b/src/Constraints/ContainsSelector.php index 0cd9348..6c8f67e 100644 --- a/src/Constraints/ContainsSelector.php +++ b/src/Constraints/ContainsSelector.php @@ -11,6 +11,8 @@ */ class ContainsSelector extends Constraint { + use ExporterTrait; + /** * @var Selector */ @@ -21,6 +23,8 @@ class ContainsSelector extends Constraint */ public function __construct(Selector $selector) { + parent::__construct(); + $this->selector = $selector; } @@ -31,7 +35,7 @@ public function __construct(Selector $selector) */ public function toString(): string { - return 'contains selector ' . $this->exporter()->export($this->selector->getValue()); + return 'contains selector ' . $this->exportValue($this->selector->getValue()); } /** diff --git a/src/Constraints/ElementContainsString.php b/src/Constraints/ElementContainsString.php index ce6e890..885984f 100644 --- a/src/Constraints/ElementContainsString.php +++ b/src/Constraints/ElementContainsString.php @@ -11,6 +11,8 @@ */ class ElementContainsString extends Constraint { + use ExporterTrait; + /** * A cache of matches that we have checked against. * @@ -41,6 +43,8 @@ class ElementContainsString extends Constraint */ public function __construct(Selector $selector, $needle, $ignore_case = false) { + parent::__construct(); + $this->selector = $selector; $this->needle = $needle; $this->ignore_case = $ignore_case; @@ -56,7 +60,7 @@ public function toString(): string return sprintf( '%s string %s', count($this->matchingElements) >= 2 ? 'contain' : 'contains', - $this->exporter()->export($this->needle) + $this->exportValue($this->needle) ); } @@ -115,7 +119,7 @@ protected function failureDescription($html): string return sprintf( $label, - $this->exporter()->export($this->selector->getValue()), + $this->exportValue($this->selector->getValue()), $this->toString() ); } diff --git a/src/Constraints/ElementMatchesRegExp.php b/src/Constraints/ElementMatchesRegExp.php index 9a4e0e4..d5e6287 100644 --- a/src/Constraints/ElementMatchesRegExp.php +++ b/src/Constraints/ElementMatchesRegExp.php @@ -23,6 +23,8 @@ class ElementMatchesRegExp extends ElementContainsString */ public function __construct(Selector $selector, string $pattern) { + parent::__construct($selector, ''); + $this->selector = $selector; $this->pattern = $pattern; } @@ -37,7 +39,7 @@ public function toString(): string return sprintf( '%s regular expression %s', count($this->matchingElements) >= 2 ? 'match' : 'matches', - $this->exporter()->export($this->pattern) + $this->exportValue($this->pattern) ); } diff --git a/src/Constraints/ExporterTrait.php b/src/Constraints/ExporterTrait.php new file mode 100644 index 0000000..da6bf02 --- /dev/null +++ b/src/Constraints/ExporterTrait.php @@ -0,0 +1,38 @@ +exporter()->export() in assertions. + * + * Instead, this trait exposes an exportValue() method that verifies that the relevant export + * method is present. + */ +trait ExporterTrait +{ + /** + * Exports a value as a string. + * + * @param mixed $value The value to be exported. + * + * @return string A string representation. + */ + protected function exportValue($value): string + { + // PHPUnit 8.x and newer only instantiate the exporter when needed. + if (method_exists($this, 'exporter') && $this->exporter() instanceof Exporter) { + return $this->exporter()->export($value); + } + + // PHPUnit 7.x creates the exporter in the constructor. + if (isset($this->exporter) && $this->exporter instanceof Exporter) { + return $this->exporter->export($value); + } + + // For everything else, just use var_export() and hope for the best. + return var_export($value, true); + } +} diff --git a/src/Constraints/SelectorCount.php b/src/Constraints/SelectorCount.php index cf3e99e..eb28ba7 100644 --- a/src/Constraints/SelectorCount.php +++ b/src/Constraints/SelectorCount.php @@ -11,6 +11,8 @@ */ class SelectorCount extends Constraint { + use ExporterTrait; + /** * @var int */ @@ -27,6 +29,8 @@ class SelectorCount extends Constraint */ public function __construct(Selector $selector, int $count) { + parent::__construct(); + $this->selector = $selector; $this->count = $count; } @@ -41,7 +45,7 @@ public function toString(): string return sprintf( 'contains %d instance(s) of selector %s', $this->count, - $this->exporter()->export($this->selector->getValue()) + $this->exportValue($this->selector->getValue()) ); }