Skip to content

[DomCrawler] Add DomCrawler to leverage PHP 8.4's HTML5-compliant DOM parser #61356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/psalm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-24.04

env:
php-version: '8.2'
php-version: '8.4'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
25 changes: 25 additions & 0 deletions UPGRADE-7.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ Console

* Deprecate `Symfony\Component\Console\Application::add()` in favor of `Symfony\Component\Console\Application::addCommand()`

BrowserKit
----------

* Leverage the native HTML5 parser when using PHP 8.4+

DependencyInjection
-------------------

Expand All @@ -29,6 +34,26 @@ DoctrineBridge

* Deprecate `UniqueEntity::getRequiredOptions()` and `UniqueEntity::getDefaultOption()`

DomCrawler
----------

* [BC BREAK] Type widening on public and protected methods/properties to support both `\Dom\*` and `\DOM*` native classes:
* `FormField::$document` property type changed from `\DOMDocument` to `\Dom\Document|\DOMDocument`
* `FormField::$xpath` property type changed from `\DOMXPath` to `\Dom\XPath|\DOMXPath`
* `FormField::$node` property type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `FormField::getLabel()` return type changed from `?\DOMElement` to `\Dom\Element|\DOMElement|null`
* `Form::getFormNode()` return type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `Form::addField()` parameter `$node` type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `AbstractUriElement::$node` property type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `AbstractUriElement::getNode()` return type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `AbstractUriElement::setNode()` parameter `$node` type changed from `\DOMElement` to `\Dom\Element|\DOMElement`
* `Crawler::add()` parameter `$node` type changed from `\DOMNodeList|\DOMNode|array|string|null` to `\Dom\NodeList|\Dom\Node|\DOMNodeList|\DOMNode|array|string|null`
* `Crawler::addDocument()` parameter `$dom` type changed from `\DOMDocument` to `\Dom\Document|\DOMDocument`
* `Crawler::addNodeList()` parameter `$nodes` type changed from `\DOMNodeList` to `\Dom\NodeList|\DOMNodeList`
* `Crawler::addNode()` parameter `$node` type changed from `\DOMNode` to `\Dom\Node|\DOMNode`
* `Crawler::getNode()` return type changed from `?\DOMNode` to `\Dom\Node|\DOMNode|null`
* `Crawler::sibling()` parameter `$node` type changed from `\DOMNode` to `\Dom\Node|\DOMNode`

FrameworkBundle
---------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\DomCrawler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -426,7 +427,7 @@ public static function mapRequestPayloadProvider(): iterable
</request>
XML,
'responseAssertion' => static function (string $response) {
$crawler = new Crawler($response);
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? new DomCrawler($response) : new Crawler($response);

self::assertSame('https://symfony.com/errors/validation', $crawler->filterXPath('response/type')->text());
self::assertSame('Validation Failed', $crawler->filterXPath('response/title')->text());
Expand Down Expand Up @@ -642,7 +643,7 @@ public static function mapRequestPayloadProvider(): iterable
</request>
XML,
'responseAssertion' => static function (string $response) {
$crawler = new Crawler($response);
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? new DomCrawler($response) : new Crawler($response);

self::assertSame('https://symfony.com/errors/validation', $crawler->filterXPath('response/type')->text());
self::assertSame('Validation Failed', $crawler->filterXPath('response/title')->text());
Expand Down Expand Up @@ -860,7 +861,7 @@ public static function mapRequestPayloadProvider(): iterable
</request>
XML,
'responseAssertion' => static function (string $response) {
$crawler = new Crawler($response);
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? new DomCrawler($response) : new Crawler($response);

self::assertSame('https://symfony.com/errors/validation', $crawler->filterXPath('response/type')->text());
self::assertSame('Validation Failed', $crawler->filterXPath('response/title')->text());
Expand Down
99 changes: 65 additions & 34 deletions src/Symfony/Bundle/FrameworkBundle/Tests/Test/WebTestCaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Symfony\Component\BrowserKit\CookieJar;
use Symfony\Component\BrowserKit\History;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\DomCrawler;
use Symfony\Component\HttpFoundation\Cookie as HttpFoundationCookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -231,126 +232,156 @@ public function testAssertBrowserHistoryIsNotOnLastPage()

public function testAssertSelectorExists()
{
$this->getCrawlerTester(new Crawler('<html><body><h1>'))->assertSelectorExists('body > h1');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><h1>'))->assertSelectorExists('body > h1');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "body > h1".');
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertSelectorExists('body > h1');
$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertSelectorExists('body > h1');
}

public function testAssertSelectorNotExists()
{
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertSelectorNotExists('body > h1');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertSelectorNotExists('body > h1');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('does not match selector "body > h1".');
$this->getCrawlerTester(new Crawler('<html><body><h1>'))->assertSelectorNotExists('body > h1');
$this->getCrawlerTester(new $crawler('<html><body><h1>'))->assertSelectorNotExists('body > h1');
}

public function testAssertSelectorCount()
{
$this->getCrawlerTester(new Crawler('<html><body><p>Hello</p></body></html>'))->assertSelectorCount(1, 'p');
$this->getCrawlerTester(new Crawler('<html><body><p>Hello</p><p>Foo</p></body></html>'))->assertSelectorCount(2, 'p');
$this->getCrawlerTester(new Crawler('<html><body><h1>This is not a paragraph.</h1></body></html>'))->assertSelectorCount(0, 'p');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><p>Hello</p></body></html>'))->assertSelectorCount(1, 'p');
$this->getCrawlerTester(new $crawler('<html><body><p>Hello</p><p>Foo</p></body></html>'))->assertSelectorCount(2, 'p');
$this->getCrawlerTester(new $crawler('<html><body><h1>This is not a paragraph.</h1></body></html>'))->assertSelectorCount(0, 'p');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('Failed asserting that the Crawler selector "p" was expected to be found 0 time(s) but was found 1 time(s).');
$this->getCrawlerTester(new Crawler('<html><body><p>Hello</p></body></html>'))->assertSelectorCount(0, 'p');
$this->getCrawlerTester(new $crawler('<html><body><p>Hello</p></body></html>'))->assertSelectorCount(0, 'p');
}

public function testAssertSelectorTextNotContains()
{
$this->getCrawlerTester(new Crawler('<html><body><h1>Foo'))->assertSelectorTextNotContains('body > h1', 'Bar');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><h1>Foo'))->assertSelectorTextNotContains('body > h1', 'Bar');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "body > h1" and the text "Foo" of the node matching selector "body > h1" does not contain "Foo".');
$this->getCrawlerTester(new Crawler('<html><body><h1>Foo'))->assertSelectorTextNotContains('body > h1', 'Foo');
$this->getCrawlerTester(new $crawler('<html><body><h1>Foo'))->assertSelectorTextNotContains('body > h1', 'Foo');
}

public function testAssertAnySelectorTextContains()
{
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Foo Baz'))->assertAnySelectorTextContains('ul li', 'Foo');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Foo Baz'))->assertAnySelectorTextContains('ul li', 'Foo');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "ul li" and the text of any node matching selector "ul li" contains "Foo".');
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextContains('ul li', 'Foo');
$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextContains('ul li', 'Foo');
}

public function testAssertAnySelectorTextSame()
{
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Foo'))->assertAnySelectorTextSame('ul li', 'Foo');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Foo'))->assertAnySelectorTextSame('ul li', 'Foo');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "ul li" and has at least a node matching selector "ul li" with content "Foo".');
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextSame('ul li', 'Foo');
$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextSame('ul li', 'Foo');
}

public function testAssertAnySelectorTextNotContains()
{
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextNotContains('ul li', 'Foo');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Baz'))->assertAnySelectorTextNotContains('ul li', 'Foo');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "ul li" and the text of any node matching selector "ul li" does not contain "Foo".');
$this->getCrawlerTester(new Crawler('<ul><li>Bar</li><li>Foo'))->assertAnySelectorTextNotContains('ul li', 'Foo');
$this->getCrawlerTester(new $crawler('<ul><li>Bar</li><li>Foo'))->assertAnySelectorTextNotContains('ul li', 'Foo');
}

public function testAssertPageTitleSame()
{
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertPageTitleSame('Foo');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertPageTitleSame('Foo');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "title" and has a node matching selector "title" with content "Bar".');
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertPageTitleSame('Bar');
$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertPageTitleSame('Bar');
}

public function testAssertPageTitleContains()
{
$this->getCrawlerTester(new Crawler('<html><head><title>Foobar'))->assertPageTitleContains('Foo');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><head><title>Foobar'))->assertPageTitleContains('Foo');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "title" and the text "Foo" of the node matching selector "title" contains "Bar".');
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertPageTitleContains('Bar');
$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertPageTitleContains('Bar');
}

public function testAssertInputValueSame()
{
$this->getCrawlerTester(new Crawler('<html><body><form><input type="text" name="username" value="Fabien">'))->assertInputValueSame('username', 'Fabien');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><form><input type="text" name="username" value="Fabien">'))->assertInputValueSame('username', 'Fabien');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "input[name="password"]" and has a node matching selector "input[name="password"]" with attribute "value" of value "pa$$".');
$this->getCrawlerTester(new Crawler('<html><head><title>Foo'))->assertInputValueSame('password', 'pa$$');
$this->getCrawlerTester(new $crawler('<html><head><title>Foo'))->assertInputValueSame('password', 'pa$$');
}

public function testAssertInputValueNotSame()
{
$this->getCrawlerTester(new Crawler('<html><body><input type="text" name="username" value="Helene">'))->assertInputValueNotSame('username', 'Fabien');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><input type="text" name="username" value="Helene">'))->assertInputValueNotSame('username', 'Fabien');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "input[name="password"]" and does not have a node matching selector "input[name="password"]" with attribute "value" of value "pa$$".');
$this->getCrawlerTester(new Crawler('<html><body><form><input type="text" name="password" value="pa$$">'))->assertInputValueNotSame('password', 'pa$$');
$this->getCrawlerTester(new $crawler('<html><body><form><input type="text" name="password" value="pa$$">'))->assertInputValueNotSame('password', 'pa$$');
}

public function testAssertCheckboxChecked()
{
$this->getCrawlerTester(new Crawler('<html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxChecked('rememberMe');
$this->getCrawlerTester(new Crawler('<!DOCTYPE html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxChecked('rememberMe');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxChecked('rememberMe');
$this->getCrawlerTester(new $crawler('<!DOCTYPE html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxChecked('rememberMe');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('matches selector "input[name="rememberMe"]:checked".');
$this->getCrawlerTester(new Crawler('<html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxChecked('rememberMe');
$this->getCrawlerTester(new $crawler('<html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxChecked('rememberMe');
}

public function testAssertCheckboxNotChecked()
{
$this->getCrawlerTester(new Crawler('<html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxNotChecked('rememberMe');
$this->getCrawlerTester(new Crawler('<!DOCTYPE html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxNotChecked('rememberMe');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxNotChecked('rememberMe');
$this->getCrawlerTester(new $crawler('<!DOCTYPE html><body><form><input type="checkbox" name="rememberMe">'))->assertCheckboxNotChecked('rememberMe');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('does not match selector "input[name="rememberMe"]:checked".');
$this->getCrawlerTester(new Crawler('<html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxNotChecked('rememberMe');
$this->getCrawlerTester(new $crawler('<html><body><form><input type="checkbox" name="rememberMe" checked>'))->assertCheckboxNotChecked('rememberMe');
}

public function testAssertFormValue()
{
$this->getCrawlerTester(new Crawler('<html><body><form id="form"><input type="text" name="username" value="Fabien">', 'http://localhost'))->assertFormValue('#form', 'username', 'Fabien');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><form id="form"><input type="text" name="username" value="Fabien">', 'http://localhost'))->assertFormValue('#form', 'username', 'Fabien');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('Failed asserting that two strings are identical.');
$this->getCrawlerTester(new Crawler('<html><body><form id="form"><input type="text" name="username" value="Fabien">', 'http://localhost'))->assertFormValue('#form', 'username', 'Jane');
$this->getCrawlerTester(new $crawler('<html><body><form id="form"><input type="text" name="username" value="Fabien">', 'http://localhost'))->assertFormValue('#form', 'username', 'Jane');
}

public function testAssertNoFormValue()
{
$this->getCrawlerTester(new Crawler('<html><body><form id="form"><input type="checkbox" name="rememberMe">', 'http://localhost'))->assertNoFormValue('#form', 'rememberMe');
$crawler = \PHP_VERSION_ID >= 80400 && class_exists(DomCrawler::class) ? DomCrawler::class : Crawler::class;

$this->getCrawlerTester(new $crawler('<html><body><form id="form"><input type="checkbox" name="rememberMe">', 'http://localhost'))->assertNoFormValue('#form', 'rememberMe');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('Field "rememberMe" has a value in form "#form".');
$this->getCrawlerTester(new Crawler('<html><body><form id="form"><input type="checkbox" name="rememberMe" checked>', 'http://localhost'))->assertNoFormValue('#form', 'rememberMe');
$this->getCrawlerTester(new $crawler('<html><body><form id="form"><input type="checkbox" name="rememberMe" checked>', 'http://localhost'))->assertNoFormValue('#form', 'rememberMe');
}

public function testAssertRequestAttributeValueSame()
Expand Down
Loading
Loading