From 24358551ace790bc4017a4070d413fb91a4525c4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Thu, 21 Mar 2024 10:21:26 +0100 Subject: [PATCH 1/2] [DomCrawler] Support classes from the new DOM extension --- .../DomCrawler/AbstractUriElement.php | 48 +- src/Symfony/Component/DomCrawler/CHANGELOG.md | 5 + .../Component/DomCrawler/DomCrawler.php | 1246 +++++++++++++++ .../DomCrawler/Field/ChoiceFormField.php | 29 +- .../DomCrawler/Field/DomFormField.php | 92 ++ .../DomCrawler/Field/FileFormField.php | 12 +- .../DomCrawler/Field/InputFormField.php | 11 +- .../DomCrawler/Field/TextareaFormField.php | 9 +- src/Symfony/Component/DomCrawler/Form.php | 98 +- src/Symfony/Component/DomCrawler/Image.php | 27 +- src/Symfony/Component/DomCrawler/Link.php | 20 +- .../Tests/AbstractCrawlerTestCase.php | 45 +- .../DomCrawler/Tests/DomCrawlerTest.php | 1399 +++++++++++++++++ .../DomCrawler/Tests/Field/FormFieldTest.php | 27 +- .../Component/DomCrawler/Tests/FormTest.php | 692 +++++--- .../Component/DomCrawler/Tests/ImageTest.php | 54 +- .../Component/DomCrawler/Tests/LinkTest.php | 146 +- .../Component/DomCrawler/composer.json | 1 + 18 files changed, 3642 insertions(+), 319 deletions(-) create mode 100644 src/Symfony/Component/DomCrawler/DomCrawler.php create mode 100644 src/Symfony/Component/DomCrawler/Field/DomFormField.php create mode 100644 src/Symfony/Component/DomCrawler/Tests/DomCrawlerTest.php diff --git a/src/Symfony/Component/DomCrawler/AbstractUriElement.php b/src/Symfony/Component/DomCrawler/AbstractUriElement.php index 5ec7b3ed3d0ad..2ade5c1f90660 100644 --- a/src/Symfony/Component/DomCrawler/AbstractUriElement.php +++ b/src/Symfony/Component/DomCrawler/AbstractUriElement.php @@ -18,22 +18,26 @@ */ abstract class AbstractUriElement { + /** + * @deprecated since Symfony 7.1, use `$domeNode` instead + */ protected \DOMElement $node; + protected \DOMElement|\DOM\Element $domNode; protected ?string $method; /** - * @param \DOMElement $node A \DOMElement instance - * @param string|null $currentUri The URI of the page where the link is embedded (or the base href) - * @param string|null $method The method to use for the link (GET by default) + * @param \DOMElement|\DOM\Element $node A \DOMElement or a \DOM\Element instance + * @param string|null $currentUri The URI of the page where the link is embedded (or the base href) + * @param string|null $method The method to use for the link (GET by default) * * @throws \InvalidArgumentException if the node is not a link */ public function __construct( - \DOMElement $node, + \DOMElement|\DOM\Element $node, protected ?string $currentUri = null, ?string $method = 'GET', ) { - $this->setNode($node); + $this->setDomNode($node); $this->method = $method ? strtoupper($method) : null; $elementUriIsRelative = !parse_url(trim($this->getRawUri()), \PHP_URL_SCHEME); @@ -44,11 +48,25 @@ public function __construct( } /** - * Gets the node associated with this link. + * @deprecated since Symfony 7.1, use `getDomNode()` instead */ public function getNode(): \DOMElement { - return $this->node; + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::getDomNode()" instead.', __METHOD__, __CLASS__); + + if ($this->domNode instanceof \DOM\Element) { + throw new \LogicException('The node is not an instance of legacy \DOMElement. Use "getDomNode()" instead.'); + } + + return $this->domNode; + } + + /** + * Gets the node associated with this link. + */ + public function getDomNode(): \DOMElement|\DOM\Element + { + return $this->domNode; } /** @@ -108,4 +126,20 @@ protected function canonicalizePath(string $path): string * @throws \LogicException If given node is not an anchor */ abstract protected function setNode(\DOMElement $node): void; + + /** + * Sets current \DOMElement or \DOM\Element instance. + * + * @param \DOMElement|\DOM\Element $node A \DOMElement or \DOM\Element instance + * + * @throws \LogicException If given node is not an anchor + */ + protected function setDomNode(\DOMElement|\DOM\Element $node): void + { + $this->domNode = $node; + + if ($node instanceof \DOMElement) { + $this->setNode($node); + } + } } diff --git a/src/Symfony/Component/DomCrawler/CHANGELOG.md b/src/Symfony/Component/DomCrawler/CHANGELOG.md index 53395956f3be9..a211ba082126e 100644 --- a/src/Symfony/Component/DomCrawler/CHANGELOG.md +++ b/src/Symfony/Component/DomCrawler/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add `DomCrawler` to parse HTML and XML with native capabilities + 7.0 --- diff --git a/src/Symfony/Component/DomCrawler/DomCrawler.php b/src/Symfony/Component/DomCrawler/DomCrawler.php new file mode 100644 index 0000000000000..8c87c6885f3f7 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/DomCrawler.php @@ -0,0 +1,1246 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +use Symfony\Component\CssSelector\CssSelectorConverter; + +/** + * Crawler eases navigation of a list of \DOM\Node objects. + * + * @author Fabien Potencier + * @author Alexandre Daubois + * + * @implements \IteratorAggregate + */ +final class DomCrawler implements \Countable, \IteratorAggregate +{ + public const CRAWLER_DISABLE_DEFAULT_NAMESPACE = 1; + + /** + * The default namespace prefix to be used with XPath and CSS expressions. + * + * @internal + */ + private string $defaultNamespacePrefix = 'default'; + + /** + * A map of manually registered namespaces. + * + * @internal + * + * @var array + */ + private array $namespaces = []; + + /** + * A map of cached namespaces. + * + * @internal + */ + private \ArrayObject $cachedNamespaces; + + /** + * @internal + */ + private ?string $baseHref; + + private ?\DOM\Document $document = null; + + /** + * @var list<\DOM\Node> + * + * @interal + */ + private array $nodes = []; + + /** + * Whether the Crawler contains HTML or XML content (used when converting CSS to XPath). + * + * @internal + */ + private bool $isHtml = true; + + /** + * @param \DOM\NodeList|\DOM\Node|\DOM\Node[]|string|null $node A Node to use as the base for the crawling + */ + public function __construct( + \DOM\NodeList|\DOM\Node|array|string|null $node = null, + private ?string $uri = null, + ?string $baseHref = null, + private int $options = 0, + ) { + $this->baseHref = $baseHref ?: $uri; + $this->cachedNamespaces = new \ArrayObject(); + + $this->add($node); + } + + /** + * Returns the current URI. + */ + public function getUri(): ?string + { + return $this->uri; + } + + /** + * Returns base href. + */ + public function getBaseHref(): ?string + { + return $this->baseHref; + } + + /** + * Removes all the nodes. + */ + public function clear(): void + { + $this->nodes = []; + $this->document = null; + $this->cachedNamespaces = new \ArrayObject(); + } + + /** + * Adds a node to the current list of nodes. + * + * This method uses the appropriate specialized add*() method based + * on the type of the argument. + * + * @param \DOM\NodeList|\DOM\Node|\DOM\Node[]|string|null $node A node + * + * @throws \InvalidArgumentException when node is not the expected type + */ + public function add(\DOM\NodeList|\DOM\Node|array|string|null $node): void + { + if ($node instanceof \DOM\NodeList) { + $this->addNodeList($node); + } elseif ($node instanceof \DOM\Node) { + $this->addNode($node); + } elseif (\is_array($node)) { + $this->addNodes($node); + } elseif (\is_string($node)) { + $this->addContent($node); + } elseif (null !== $node) { + throw new \InvalidArgumentException(\sprintf('Expecting a DOM\NodeList or DOM\Node instance, an array, a string, or null, but got "%s".', get_debug_type($node))); + } + } + + /** + * Adds HTML/XML content. + * + * If the charset is not set via the content type, it is assumed to be UTF-8, + * or ISO-8859-1 as a fallback, which is the default charset defined by the + * HTTP 1.1 specification. + */ + public function addContent(string $content, ?string $type = null): void + { + if (!$type) { + $type = str_starts_with($content, 'convertToHtmlEntities('charset=', $m[2])) { + $charset = $m[2]; + } + + return $m[1].$charset; + }, $content, 1); + + if ('x' === $xmlMatches[1]) { + $this->addXmlContent($content, $charset); + } else { + $this->addHtmlContent($content, $charset); + } + } + + /** + * Adds an HTML content to the list of nodes. + * + * The libxml errors are disabled when the content is parsed. + * + * If you want to get parsing errors, be sure to enable + * internal errors via libxml_use_internal_errors(true) + * and then, get the errors via libxml_get_errors(). Be + * sure to clear errors with libxml_clear_errors() afterward. + */ + public function addHtmlContent(string $content, string $charset = 'UTF-8'): void + { + $dom = $this->parseHtmlString($content, $charset); + $this->addDocument($dom); + + $base = $this->filterRelativeXPath('descendant-or-self::base')->extract(['href']); + + $baseHref = current($base); + if (\count($base) && !empty($baseHref)) { + if ($this->baseHref) { + $linkNode = $dom->createElement('a'); + $linkNode->setAttribute('href', $baseHref); + $link = new Link($linkNode, $this->baseHref); + $this->baseHref = $link->getUri(); + } else { + $this->baseHref = $baseHref; + } + } + } + + /** + * Adds an XML content to the list of nodes. + * + * The libxml errors are disabled when the content is parsed. + * + * If you want to get parsing errors, be sure to enable + * internal errors via libxml_use_internal_errors(true) + * and then, get the errors via libxml_get_errors(). Be + * sure to clear errors with libxml_clear_errors() afterward. + * + * @param int $options Bitwise OR of the libxml option constants + * LIBXML_PARSEHUGE is dangerous, see + * http://symfony.com/blog/security-release-symfony-2-0-17-released + */ + public function addXmlContent(string $content, string $charset = 'UTF-8', int $options = \LIBXML_NONET): void + { + // remove the default namespace if it's the only namespace to make XPath expressions simpler + if (!str_contains($content, 'xmlns:')) { + $content = str_replace('xmlns', 'ns', $content); + } + + $internalErrors = libxml_use_internal_errors(true); + + try { + $dom = \DOM\XMLDocument::createFromString($content, $options); + } catch (\Exception) { + $dom = \DOM\XMLDocument::createEmpty(); + } + + libxml_use_internal_errors($internalErrors); + + $this->addDocument($dom); + + $this->isHtml = false; + } + + /** + * Adds a \DOM\Document to the list of nodes. + */ + public function addDocument(\DOM\Document $dom): void + { + if ($dom->documentElement) { + $this->addNode($dom->documentElement); + } + } + + /** + * Adds a \DOM\NodeList to the list of nodes. + */ + public function addNodeList(\DOM\NodeList $nodes): void + { + foreach ($nodes as $node) { + if ($node instanceof \DOM\Node) { + $this->addNode($node); + } + } + } + + /** + * Adds an array of \DOMNode instances to the list of nodes. + * + * @param \DOM\Node[] $nodes An array of \DOM\Node instances + */ + public function addNodes(array $nodes): void + { + foreach ($nodes as $node) { + $this->add($node); + } + } + + /** + * Adds a \DOM\Node instance to the list of nodes. + */ + public function addNode(\DOM\Node $node): void + { + if ($node instanceof \DOM\Document) { + $node = $node->documentElement; + } + + if (null !== $this->document && $this->document !== $node->ownerDocument) { + throw new \InvalidArgumentException('Attaching DOM nodes from multiple documents in the same crawler is forbidden.'); + } + + $this->document ??= $node->ownerDocument; + + // Don't add duplicate nodes in the Crawler + if (\in_array($node, $this->nodes, true)) { + return; + } + + $this->nodes[] = $node; + } + + /** + * Returns a node given its position in the node list. + */ + public function eq(int $position): static + { + if (isset($this->nodes[$position])) { + return $this->createSubCrawler($this->nodes[$position]); + } + + return $this->createSubCrawler(null); + } + + /** + * Calls an anonymous function on each node of the list. + * + * The anonymous function receives the position and the node wrapped + * in a Crawler instance as arguments. + * + * Example: + * + * $crawler->filter('h1')->each(function ($node, $i) { + * return $node->text(); + * }); + * + * @param \Closure $closure An anonymous function + * + * @return array An array of values returned by the anonymous function + */ + public function each(\Closure $closure): array + { + $data = []; + foreach ($this->nodes as $i => $node) { + $data[] = $closure($this->createSubCrawler($node), $i); + } + + return $data; + } + + /** + * Slices the list of nodes by $offset and $length. + */ + public function slice(int $offset = 0, ?int $length = null): static + { + return $this->createSubCrawler(\array_slice($this->nodes, $offset, $length)); + } + + /** + * Reduces the list of nodes by calling an anonymous function. + * + * To remove a node from the list, the anonymous function must return false. + * + * @param \Closure $closure An anonymous function + */ + public function reduce(\Closure $closure): static + { + $nodes = []; + foreach ($this->nodes as $i => $node) { + if (false !== $closure($this->createSubCrawler($node), $i)) { + $nodes[] = $node; + } + } + + return $this->createSubCrawler($nodes); + } + + /** + * Returns the first node of the current selection. + */ + public function first(): static + { + return $this->eq(0); + } + + /** + * Returns the last node of the current selection. + */ + public function last(): static + { + return $this->eq(\count($this->nodes) - 1); + } + + /** + * Returns the siblings nodes of the current selection. + * + * @throws \InvalidArgumentException When current node is empty + */ + public function siblings(): static + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0)->parentNode->firstChild)); + } + + public function matches(string $selector): bool + { + if (!$this->nodes) { + return false; + } + + $converter = $this->createCssSelectorConverter(); + $xpath = $converter->toXPath($selector, 'self::'); + + return 0 !== $this->filterRelativeXPath($xpath)->count(); + } + + /** + * Return first parents (heading toward the document root) of the Element that matches the provided selector. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill + * + * @throws \InvalidArgumentException When current node is empty + */ + public function closest(string $selector): ?self + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $domNode = $this->getNode(0); + + while (\XML_ELEMENT_NODE === $domNode->nodeType) { + $node = $this->createSubCrawler($domNode); + if ($node->matches($selector)) { + return $node; + } + + $domNode = $node->getNode(0)->parentNode; + } + + return null; + } + + /** + * Returns the next siblings nodes of the current selection. + * + * @throws \InvalidArgumentException When current node is empty + */ + public function nextAll(): static + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0))); + } + + /** + * Returns the previous sibling nodes of the current selection. + * + * @throws \InvalidArgumentException + */ + public function previousAll(): static + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0), 'previousSibling')); + } + + /** + * Returns the ancestors of the current selection. + * + * @throws \InvalidArgumentException When the current node is empty + */ + public function ancestors(): static + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $nodes = []; + + while ($node = $node->parentNode) { + if (\XML_ELEMENT_NODE === $node->nodeType) { + $nodes[] = $node; + } + } + + return $this->createSubCrawler($nodes); + } + + /** + * Returns the children nodes of the current selection. + * + * @throws \InvalidArgumentException When current node is empty + * @throws \RuntimeException If the CssSelector Component is not available and $selector is provided + */ + public function children(?string $selector = null): static + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + if (null !== $selector) { + $converter = $this->createCssSelectorConverter(); + $xpath = $converter->toXPath($selector, 'child::'); + + return $this->filterRelativeXPath($xpath); + } + + $node = $this->getNode(0)->firstChild; + + return $this->createSubCrawler($node ? $this->sibling($node) : []); + } + + /** + * Returns the attribute value of the first node of the list. + * + * @param string|null $default When not null: the value to return when the node or attribute is empty + * + * @throws \InvalidArgumentException When current node is empty + */ + public function attr(string $attribute, ?string $default = null): ?string + { + if (!$this->nodes) { + if (null !== $default) { + return $default; + } + + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + return $node->hasAttribute($attribute) ? $node->getAttribute($attribute) : $default; + } + + /** + * Returns the node name of the first node of the list. + * + * @throws \InvalidArgumentException When current node is empty + */ + public function nodeName(): string + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->getNode(0)->nodeName; + } + + /** + * Returns the text of the first node of the list. + * + * Pass true as the second argument to normalize whitespaces. + * + * @param string|null $default When not null: the value to return when the current node is empty + * @param bool $normalizeWhitespace Whether whitespaces should be trimmed and normalized to single spaces + * + * @throws \InvalidArgumentException When current node is empty + */ + public function text(?string $default = null, bool $normalizeWhitespace = true): string + { + if (!$this->nodes) { + if (null !== $default) { + return $default; + } + + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $text = $node->nodeValue ?? $node->textContent; + + if ($normalizeWhitespace) { + return $this->normalizeWhitespace($text); + } + + return $text; + } + + /** + * Returns only the inner text that is the direct descendent of the current node, excluding any child nodes. + * + * @param bool $normalizeWhitespace Whether whitespaces should be trimmed and normalized to single spaces + */ + public function innerText(bool $normalizeWhitespace = true): string + { + foreach ($this->getNode(0)->childNodes as $childNode) { + if (\XML_TEXT_NODE !== $childNode->nodeType && \XML_CDATA_SECTION_NODE !== $childNode->nodeType) { + continue; + } + $content = $childNode->nodeValue ?? $childNode->textContent; + if (!$normalizeWhitespace) { + return $content; + } + if ('' !== trim($content)) { + return $this->normalizeWhitespace($content); + } + } + + return ''; + } + + /** + * Returns the first node of the list as HTML. + * + * @param string|null $default When not null: the value to return when the current node is empty + * + * @throws \InvalidArgumentException When current node is empty + */ + public function html(?string $default = null): string + { + if (!$this->nodes) { + if (null !== $default) { + return $default; + } + + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $owner = $node->ownerDocument; + + if ($owner instanceof \DOM\XMLDocument) { + return $owner->saveXML($node); + } + + $html = ''; + foreach ($node->childNodes as $child) { + $html .= $owner->saveHTML($child); + } + + return $html; + } + + public function outerHtml(): string + { + if (!\count($this)) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $owner = $node->ownerDocument; + + return $owner->saveHTML($node); + } + + /** + * Evaluates an XPath expression. + * + * Since an XPath expression might evaluate to either a simple type or a \DOMNodeList, + * this method will return either an array of simple types or a new Crawler instance. + */ + public function evaluate(string $xpath): array|self + { + if (null === $this->document) { + throw new \LogicException('Cannot evaluate the expression on an uninitialized crawler.'); + } + + $data = []; + $domxpath = $this->createDOMXPath($this->document, $this->findNamespacePrefixes($xpath)); + + foreach ($this->nodes as $node) { + $data[] = $domxpath->evaluate($xpath, $node); + } + + if (isset($data[0]) && $data[0] instanceof \DOM\NodeList) { + return $this->createSubCrawler($data); + } + + return $data; + } + + /** + * Extracts information from the list of nodes. + * + * You can extract attributes or/and the node value (_text). + * + * Example: + * + * $crawler->filter('h1 a')->extract(['_text', 'href']); + */ + public function extract(array $attributes): array + { + $count = \count($attributes); + + $data = []; + foreach ($this->nodes as $node) { + $elements = []; + foreach ($attributes as $attribute) { + if ('_text' === $attribute) { + $elements[] = $node->nodeValue ?? $node->textContent; + } elseif ('_name' === $attribute) { + $elements[] = $node->nodeName; + } else { + $elements[] = $node->getAttribute($attribute) ?? ''; + } + } + + $data[] = 1 === $count ? $elements[0] : $elements; + } + + return $data; + } + + /** + * Filters the list of nodes with an XPath expression. + * + * The XPath expression is evaluated in the context of the crawler, which + * is considered as a fake parent of the elements inside it. + * This means that a child selector "div" or "./div" will match only + * the div elements of the current crawler, not their children. + */ + public function filterXPath(string $xpath): static + { + $xpath = $this->relativize($xpath); + + // If we dropped all expressions in the XPath while preparing it, there would be no match + if ('' === $xpath) { + return $this->createSubCrawler(null); + } + + return $this->filterRelativeXPath($xpath); + } + + /** + * Filters the list of nodes with a CSS selector. + * + * This method only works if you have installed the CssSelector Symfony Component. + * + * @throws \LogicException if the CssSelector Component is not available + */ + public function filter(string $selector): static + { + $converter = $this->createCssSelectorConverter(); + + // The CssSelector already prefixes the selector with descendant-or-self:: + return $this->filterRelativeXPath($converter->toXPath($selector)); + } + + /** + * Selects links by name or alt value for clickable images. + */ + public function selectLink(string $value): static + { + return $this->filterRelativeXPath( + \sprintf('descendant-or-self::a[contains(concat(\' \', normalize-space(string(.)), \' \'), %1$s) or ./img[contains(concat(\' \', normalize-space(string(@alt)), \' \'), %1$s)]]', static::xpathLiteral(' '.$value.' ')) + ); + } + + /** + * Selects images by alt value. + */ + public function selectImage(string $value): static + { + $xpath = \sprintf('descendant-or-self::img[contains(normalize-space(string(@alt)), %s)]', static::xpathLiteral($value)); + + return $this->filterRelativeXPath($xpath); + } + + /** + * Selects a button by name or alt value for images. + */ + public function selectButton(string $value): static + { + return $this->filterRelativeXPath( + \sprintf('descendant-or-self::input[((contains(%1$s, "submit") or contains(%1$s, "button")) and contains(concat(\' \', normalize-space(string(@value)), \' \'), %2$s)) or (contains(%1$s, "image") and contains(concat(\' \', normalize-space(string(@alt)), \' \'), %2$s)) or @id=%3$s or @name=%3$s] | descendant-or-self::button[contains(concat(\' \', normalize-space(string(.)), \' \'), %2$s) or @id=%3$s or @name=%3$s]', 'translate(@type, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")', static::xpathLiteral(' '.$value.' '), static::xpathLiteral($value)) + ); + } + + /** + * Returns a Link object for the first node in the list. + * + * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement + */ + public function link(string $method = 'get'): Link + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOM\Element) { + throw new \InvalidArgumentException(\sprintf('The selected node should be instance of "DOM\Element", got "%s".', get_debug_type($node))); + } + + return new Link($node, $this->baseHref, $method); + } + + /** + * Returns an array of Link objects for the nodes in the list. + * + * @return Link[] + * + * @throws \InvalidArgumentException If the current node list contains non-DOMElement instances + */ + public function links(): array + { + $links = []; + + foreach ($this->nodes as $node) { + if (!$node instanceof \DOM\Element) { + throw new \InvalidArgumentException(\sprintf('The current node list should contain only DOM\Element instances, "%s" found.', get_debug_type($node))); + } + + $links[] = new Link($node, $this->baseHref, 'get'); + } + + return $links; + } + + /** + * Returns an Image object for the first node in the list. + * + * @throws \InvalidArgumentException If the current node list is empty + */ + public function image(): Image + { + if (!\count($this)) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOM\Element) { + throw new \InvalidArgumentException(\sprintf('The selected node should be instance of "DOM\Element", got "%s".', get_debug_type($node))); + } + + return new Image($node, $this->baseHref); + } + + /** + * Returns an array of Image objects for the nodes in the list. + * + * @return Image[] + */ + public function images(): array + { + $images = []; + foreach ($this as $node) { + if (!$node instanceof \DOM\Element) { + throw new \InvalidArgumentException(\sprintf('The current node list should contain only DOM\Element instances, "%s" found.', get_debug_type($node))); + } + + $images[] = new Image($node, $this->baseHref); + } + + return $images; + } + + /** + * Returns a Form object for the first node in the list. + * + * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement + */ + public function form(?array $values = null, ?string $method = null): Form + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOM\Element) { + throw new \InvalidArgumentException(\sprintf('The selected node should be instance of "DOM\Element", got "%s".', get_debug_type($node))); + } + + $form = new Form($node, $this->uri, $method, $this->baseHref); + + if (null !== $values) { + $form->setValues($values); + } + + return $form; + } + + /** + * Overloads a default namespace prefix to be used with XPath and CSS expressions. + */ + public function setDefaultNamespacePrefix(string $prefix): void + { + $this->defaultNamespacePrefix = $prefix; + } + + public function registerNamespace(string $prefix, string $namespace): void + { + $this->namespaces[$prefix] = $namespace; + } + + /** + * Converts string for XPath expressions. + * + * Escaped characters are: quotes (") and apostrophe ('). + * + * Examples: + * + * echo Crawler::xpathLiteral('foo " bar'); + * //prints 'foo " bar' + * + * echo Crawler::xpathLiteral("foo ' bar"); + * //prints "foo ' bar" + * + * echo Crawler::xpathLiteral('a\'b"c'); + * //prints concat('a', "'", 'b"c') + */ + public static function xpathLiteral(string $s): string + { + if (!str_contains($s, "'")) { + return \sprintf("'%s'", $s); + } + + if (!str_contains($s, '"')) { + return \sprintf('"%s"', $s); + } + + $string = $s; + $parts = []; + while (true) { + if (false !== $pos = strpos($string, "'")) { + $parts[] = \sprintf("'%s'", substr($string, 0, $pos)); + $parts[] = "\"'\""; + $string = substr($string, $pos + 1); + } else { + $parts[] = "'$string'"; + break; + } + } + + return \sprintf('concat(%s)', implode(', ', $parts)); + } + + /** + * Filters the list of nodes with an XPath expression. + * + * The XPath expression should already be processed to apply it in the context of each node. + * + * @internal + */ + private function filterRelativeXPath(string $xpath): static + { + $crawler = $this->createSubCrawler(null); + if (null === $this->document) { + return $crawler; + } + + $domxpath = $this->createDOMXPath($this->document, $this->findNamespacePrefixes($xpath)); + + foreach ($this->nodes as $node) { + $crawler->add($domxpath->query($xpath, $node)); + } + + return $crawler; + } + + /** + * Make the XPath relative to the current context. + * + * The returned XPath will match elements matching the XPath inside the current crawler + * when running in the context of a node of the crawler. + */ + private function relativize(string $xpath): string + { + $expressions = []; + + // An expression which will never match to replace expressions which cannot match in the crawler + // We cannot drop + $nonMatchingExpression = 'a[name() = "b"]'; + + $xpathLen = \strlen($xpath); + $openedBrackets = 0; + $startPosition = strspn($xpath, " \t\n\r\0\x0B"); + + for ($i = $startPosition; $i <= $xpathLen; ++$i) { + $i += strcspn($xpath, '"\'[]|', $i); + + if ($i < $xpathLen) { + switch ($xpath[$i]) { + case '"': + case "'": + if (false === $i = strpos($xpath, $xpath[$i], $i + 1)) { + return $xpath; // The XPath expression is invalid + } + continue 2; + case '[': + ++$openedBrackets; + continue 2; + case ']': + --$openedBrackets; + continue 2; + } + } + if ($openedBrackets) { + continue; + } + + if ($startPosition < $xpathLen && '(' === $xpath[$startPosition]) { + // If the union is inside some braces, we need to preserve the opening braces and apply + // the change only inside it. + $j = 1 + strspn($xpath, "( \t\n\r\0\x0B", $startPosition + 1); + $parenthesis = substr($xpath, $startPosition, $j); + $startPosition += $j; + } else { + $parenthesis = ''; + } + $expression = rtrim(substr($xpath, $startPosition, $i - $startPosition)); + + if (str_starts_with($expression, 'self::*/')) { + $expression = './'.substr($expression, 8); + } + + // add prefix before absolute element selector + if ('' === $expression) { + $expression = $nonMatchingExpression; + } elseif (str_starts_with($expression, '//')) { + $expression = 'descendant-or-self::'.substr($expression, 2); + } elseif (str_starts_with($expression, './/')) { + $expression = 'descendant-or-self::'.substr($expression, 3); + } elseif (str_starts_with($expression, './')) { + $expression = 'self::'.substr($expression, 2); + } elseif (str_starts_with($expression, 'child::')) { + $expression = 'self::'.substr($expression, 7); + } elseif ('/' === $expression[0] || '.' === $expression[0] || str_starts_with($expression, 'self::')) { + $expression = $nonMatchingExpression; + } elseif (str_starts_with($expression, 'descendant::')) { + $expression = 'descendant-or-self::'.substr($expression, 12); + } elseif (preg_match('/^(ancestor|ancestor-or-self|attribute|following|following-sibling|namespace|parent|preceding|preceding-sibling)::/', $expression)) { + // the fake root has no parent, preceding or following nodes and also no attributes (even no namespace attributes) + $expression = $nonMatchingExpression; + } elseif (!str_starts_with($expression, 'descendant-or-self::')) { + $expression = 'self::'.$expression; + } + $expressions[] = $parenthesis.$expression; + + if ($i === $xpathLen) { + return implode(' | ', $expressions); + } + + $i += strspn($xpath, " \t\n\r\0\x0B", $i + 1); + $startPosition = $i + 1; + } + + return $xpath; // The XPath expression is invalid + } + + public function getNode(int $position): ?\DOM\Node + { + return $this->nodes[$position] ?? null; + } + + public function count(): int + { + return \count($this->nodes); + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->nodes); + } + + private function sibling(\DOM\Node $node, string $siblingDir = 'nextSibling'): array + { + $nodes = []; + + $currentNode = $this->getNode(0); + do { + if ($node !== $currentNode && \XML_ELEMENT_NODE === $node->nodeType) { + $nodes[] = $node; + } + } while ($node = $node->$siblingDir); + + return $nodes; + } + + private function parseHtml5(string $htmlContent, string $charset = 'UTF-8'): \DOM\HTMLDocument + { + return \DOM\HTMLDocument::createFromString($htmlContent, $this->options & self::CRAWLER_DISABLE_DEFAULT_NAMESPACE ? \DOM\HTML_NO_DEFAULT_NS : 0, $charset); + } + + private function supportsEncoding(string $encoding): bool + { + try { + return '' === @mb_convert_encoding('', $encoding, 'UTF-8'); + } catch (\Throwable $e) { + return false; + } + } + + private function parseXhtml(string $htmlContent, string $charset = 'UTF-8'): \DOM\XMLDocument + { + if ('UTF-8' === $charset && preg_match('//u', $htmlContent)) { + $htmlContent = ''.$htmlContent; + } else { + $htmlContent = $this->convertToHtmlEntities($htmlContent, $charset); + } + + $internalErrors = libxml_use_internal_errors(true); + + try { + $dom = \DOM\XMLDocument::createFromString($htmlContent); + } catch (\Exception) { + // like with legacy nodes, create an empty document if + // content cannot be loaded + $dom = \DOM\XMLDocument::createEmpty(); + } + + libxml_use_internal_errors($internalErrors); + + return $dom; + } + + /** + * Converts charset to HTML-entities to ensure valid parsing. + * + * @internal + */ + private function convertToHtmlEntities(string $htmlContent, string $charset = 'UTF-8'): string + { + set_error_handler(static fn () => throw new \Exception()); + + try { + return mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], $charset); + } catch (\Exception|\ValueError) { + try { + $htmlContent = iconv($charset, 'UTF-8', $htmlContent); + $htmlContent = mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8'); + } catch (\Exception|\ValueError) { + } + + return $htmlContent; + } finally { + restore_error_handler(); + } + } + + /** + * @throws \InvalidArgumentException + */ + private function createDOMXPath(\DOM\Document $document, array $prefixes = []): \DOM\XPath + { + $domxpath = new \DOM\XPath($document); + $this->registerKnownNamespacesInXPath($domxpath); + + return $domxpath; + } + + private function registerKnownNamespacesInXPath(\DOM\XPath $domxpath): void + { + foreach ($this->namespaces as $prefix => $namespace) { + $domxpath->registerNamespace($prefix, $namespace); + } + + foreach ($this->cachedNamespaces as $prefix => $namespace) { + $domxpath->registerNamespace($prefix, $namespace); + } + + if (null === $this->document) { + return; + } + + foreach ($domxpath->document->firstElementChild->getInScopeNamespaces() as $namespace) { + if (null !== $namespace->prefix) { + $domxpath->registerNamespace($namespace->prefix, $namespace->namespaceURI); + } else { + $domxpath->registerNamespace($this->defaultNamespacePrefix, $namespace->namespaceURI); + } + } + } + + /** + * @internal + */ + private function findNamespacePrefixes(string $xpath): array + { + if (preg_match_all('/(?P[a-z_][a-z_0-9\-\.]*+):[^"\/:]/i', $xpath, $matches)) { + return array_unique($matches['prefix']); + } + + return []; + } + + /** + * Creates a crawler for some subnodes. + * + * @param \DOM\NodeList|\DOM\Node|\DOM\Node[]|\string|null $nodes + * + * @internal + */ + private function createSubCrawler(\DOM\NodeList|\DOM\Node|array|string|null $nodes): static + { + $crawler = new static($nodes, $this->uri, $this->baseHref, $this->options); + $crawler->isHtml = $this->isHtml; + $crawler->document = $this->document; + $crawler->namespaces = $this->namespaces; + $crawler->cachedNamespaces = $this->cachedNamespaces; + + return $crawler; + } + + /** + * @throws \LogicException If the CssSelector Component is not available + */ + private function createCssSelectorConverter(): CssSelectorConverter + { + if (!class_exists(CssSelectorConverter::class)) { + throw new \LogicException('To filter with a CSS selector, install the CssSelector component ("composer require symfony/css-selector"). Or use filterXpath instead.'); + } + + return new CssSelectorConverter($this->isHtml); + } + + /** + * Parse string into DOMDocument object using HTML5 parser if the content is HTML5 and the library is available. + * Use libxml parser otherwise. + */ + private function parseHtmlString(string $content, string $charset): \DOM\Document + { + if ($this->canParseHtml5String($content)) { + return $this->parseHtml5($content, $charset); + } + + return $this->parseXhtml($content, $charset); + } + + /** + * @internal + */ + private function canParseHtml5String(string $content): bool + { + if (false === ($pos = stripos($content, ''))) { + return false; + } + + $header = substr($content, 0, $pos); + + return '' === $header || $this->isValidHtml5Heading($header); + } + + /** + * @internal + */ + private function isValidHtml5Heading(string $heading): bool + { + return 1 === preg_match('/^\x{FEFF}?\s*(\s*)*$/u', $heading); + } + + private function normalizeWhitespace(string $string): string + { + return trim(preg_replace("/(?:[ \n\r\t\x0C]{2,}+|[\n\r\t\x0C])/", ' ', $string), " \n\r\t\x0C"); + } +} diff --git a/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php b/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php index a486ff2d12a2c..a641f74ff396f 100644 --- a/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php @@ -18,7 +18,7 @@ * * @author Fabien Potencier */ -class ChoiceFormField extends FormField +class ChoiceFormField extends DomFormField { private string $type; private bool $multiple; @@ -141,7 +141,7 @@ public function setValue(string|array|bool|null $value): void * * @internal */ - public function addChoice(\DOMElement $node): void + public function addChoice(\DOMElement|\DOM\Element $node): void { if (!$this->multiple && 'radio' !== $this->type) { throw new \LogicException(\sprintf('Unable to add a choice for "%s" as it is not multiple or is not a radio button.', $this->name)); @@ -178,36 +178,37 @@ public function isMultiple(): bool */ protected function initialize(): void { - if ('input' !== $this->node->nodeName && 'select' !== $this->node->nodeName) { - throw new \LogicException(\sprintf('A ChoiceFormField can only be created from an input or select tag (%s given).', $this->node->nodeName)); + $nodeName = strtolower($this->domNode->nodeName); + if ('input' !== $nodeName && 'select' !== $nodeName) { + throw new \LogicException(\sprintf('A ChoiceFormField can only be created from an input or select tag (%s given).', $nodeName)); } - if ('input' === $this->node->nodeName && 'checkbox' !== strtolower($this->node->getAttribute('type')) && 'radio' !== strtolower($this->node->getAttribute('type'))) { - throw new \LogicException(\sprintf('A ChoiceFormField can only be created from an input tag with a type of checkbox or radio (given type is "%s").', $this->node->getAttribute('type'))); + if ('input' === $nodeName && 'checkbox' !== strtolower($this->domNode->getAttribute('type')) && 'radio' !== strtolower($this->domNode->getAttribute('type'))) { + throw new \LogicException(\sprintf('A ChoiceFormField can only be created from an input tag with a type of checkbox or radio (given type is "%s").', $this->domNode->getAttribute('type'))); } $this->value = null; $this->options = []; $this->multiple = false; - if ('input' == $this->node->nodeName) { - $this->type = strtolower($this->node->getAttribute('type')); - $optionValue = $this->buildOptionValue($this->node); + if ('input' == $nodeName) { + $this->type = strtolower($this->domNode->getAttribute('type')); + $optionValue = $this->buildOptionValue($this->domNode); $this->options[] = $optionValue; - if ($this->node->hasAttribute('checked')) { + if ($this->domNode->hasAttribute('checked')) { $this->value = $optionValue['value']; } } else { $this->type = 'select'; - if ($this->node->hasAttribute('multiple')) { + if ($this->domNode->hasAttribute('multiple')) { $this->multiple = true; $this->value = []; $this->name = str_replace('[]', '', $this->name); } $found = false; - foreach ($this->xpath->query('descendant::option', $this->node) as $option) { + foreach ($this->domXpath->query('descendant::option', $this->domNode) as $option) { $optionValue = $this->buildOptionValue($option); $this->options[] = $optionValue; @@ -231,11 +232,11 @@ protected function initialize(): void /** * Returns option value with associated disabled flag. */ - private function buildOptionValue(\DOMElement $node): array + private function buildOptionValue(\DOMElement|\DOM\Element $node): array { $option = []; - $defaultDefaultValue = 'select' === $this->node->nodeName ? '' : 'on'; + $defaultDefaultValue = 'select' === strtolower($this->domNode->nodeName) ? '' : 'on'; $defaultValue = (isset($node->nodeValue) && $node->nodeValue) ? $node->nodeValue : $defaultDefaultValue; $option['value'] = $node->hasAttribute('value') ? $node->getAttribute('value') : $defaultValue; $option['disabled'] = $node->hasAttribute('disabled'); diff --git a/src/Symfony/Component/DomCrawler/Field/DomFormField.php b/src/Symfony/Component/DomCrawler/Field/DomFormField.php new file mode 100644 index 0000000000000..4ef25e118f00f --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/DomFormField.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * FormField is the abstract class for all form fields. + * + * @author Alexandre Daubois + */ +abstract class DomFormField extends FormField +{ + protected \DOMElement|\DOM\Element $domNode; + protected \DOMDocument|\DOM\Document $domDocument; + protected \DOMXPath|\DOM\Xpath $domXpath; + protected bool $disabled = false; + + /** + * @param \DOMElement|\DOM\Element $node The node associated with this field + */ + public function __construct(\DOMElement|\DOM\Element $node) + { + $this->domNode = $node; + if ($node instanceof \DOMElement) { + $this->node = $node; + } + + $this->name = $node->getAttribute('name'); + + if ($node->ownerDocument instanceof \DOM\Document) { + $this->domXpath = new \DOM\XPath($node->ownerDocument); + } else { + $this->domXpath = new \DOMXPath($node->ownerDocument); + $this->xpath = $this->domXpath; + } + + $this->initialize(); + } + + /** + * Returns the label tag associated to the field or null if none. + * + * @deprecated since Symfony 7.1, use `getDomLabel()` instead + */ + public function getLabel(): ?\DOMElement + { + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::getDomLabel()" instead.', __METHOD__, __CLASS__); + + $element = $this->getDomLabel(); + if ($element instanceof \DOM\Element) { + throw new \LogicException(sprintf('The form is not using legacy DOM objects, you must use "%s::getDomLabel()" instead of "%s".', __CLASS__, __METHOD__)); + } + + return $element; + } + + public function getDomLabel(): \DOMElement|\DOM\Element|null + { + if ($this->domNode->ownerDocument instanceof \DOM\Document) { + $xpath = new \DOM\XPath($this->domNode->ownerDocument); + } else { + $xpath = new \DOMXPath($this->node->ownerDocument); + } + + if ($this->domNode->hasAttribute('id')) { + $labels = $xpath->query(sprintf('descendant::label[@for="%s"]', $this->domNode->getAttribute('id'))); + if ($labels->length > 0) { + return $labels->item(0); + } + } + + $labels = $xpath->query('ancestor::label[1]', $this->domNode); + + return $labels->length > 0 ? $labels->item(0) : null; + } + + /** + * Check if the current field is disabled. + */ + public function isDisabled(): bool + { + return $this->domNode->hasAttribute('disabled'); + } +} diff --git a/src/Symfony/Component/DomCrawler/Field/FileFormField.php b/src/Symfony/Component/DomCrawler/Field/FileFormField.php index 5580fd859d878..4aee83ae7ee0c 100644 --- a/src/Symfony/Component/DomCrawler/Field/FileFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/FileFormField.php @@ -16,7 +16,7 @@ * * @author Fabien Potencier */ -class FileFormField extends FormField +class FileFormField extends DomFormField { /** * Sets the PHP error code associated with the field. @@ -90,12 +90,14 @@ public function setFilePath(string $path): void */ protected function initialize(): void { - if ('input' !== $this->node->nodeName) { - throw new \LogicException(\sprintf('A FileFormField can only be created from an input tag (%s given).', $this->node->nodeName)); + $nodeName = strtolower($this->domNode->nodeName); + if ('input' !== $nodeName) { + throw new \LogicException(\sprintf('A FileFormField can only be created from an input tag (%s given).', $nodeName)); } - if ('file' !== strtolower($this->node->getAttribute('type'))) { - throw new \LogicException(\sprintf('A FileFormField can only be created from an input tag with a type of file (given type is "%s").', $this->node->getAttribute('type'))); + $attribute = strtolower($this->domNode->getAttribute('type') ?? ''); + if ('file' !== $attribute) { + throw new \LogicException(\sprintf('A FileFormField can only be created from an input tag with a type of file (given type is "%s").', $attribute)); } $this->setValue(null); diff --git a/src/Symfony/Component/DomCrawler/Field/InputFormField.php b/src/Symfony/Component/DomCrawler/Field/InputFormField.php index 1e26e5cfdd6e0..dc73725e11828 100644 --- a/src/Symfony/Component/DomCrawler/Field/InputFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/InputFormField.php @@ -19,7 +19,7 @@ * * @author Fabien Potencier */ -class InputFormField extends FormField +class InputFormField extends DomFormField { /** * Initializes the form field. @@ -28,11 +28,12 @@ class InputFormField extends FormField */ protected function initialize(): void { - if ('input' !== $this->node->nodeName && 'button' !== $this->node->nodeName) { - throw new \LogicException(\sprintf('An InputFormField can only be created from an input or button tag (%s given).', $this->node->nodeName)); + $nodeName = strtolower($this->domNode->nodeName); + if ('input' !== $nodeName && 'button' !== $nodeName) { + throw new \LogicException(\sprintf('An InputFormField can only be created from an input or button tag (%s given).', $nodeName)); } - $type = strtolower($this->node->getAttribute('type')); + $type = strtolower($this->domNode->getAttribute('type')); if ('checkbox' === $type) { throw new \LogicException('Checkboxes should be instances of ChoiceFormField.'); } @@ -41,6 +42,6 @@ protected function initialize(): void throw new \LogicException('File inputs should be instances of FileFormField.'); } - $this->value = $this->node->getAttribute('value'); + $this->value = $this->domNode->getAttribute('value') ?? ''; } } diff --git a/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php b/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php index b246776a9e4cb..210d12746771c 100644 --- a/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php @@ -16,7 +16,7 @@ * * @author Fabien Potencier */ -class TextareaFormField extends FormField +class TextareaFormField extends DomFormField { /** * Initializes the form field. @@ -25,12 +25,13 @@ class TextareaFormField extends FormField */ protected function initialize(): void { - if ('textarea' !== $this->node->nodeName) { - throw new \LogicException(\sprintf('A TextareaFormField can only be created from a textarea tag (%s given).', $this->node->nodeName)); + $nodeName = strtolower($this->domNode->nodeName); + if ('textarea' !== $nodeName) { + throw new \LogicException(\sprintf('A TextareaFormField can only be created from a textarea tag (%s given).', $nodeName)); } $this->value = ''; - foreach ($this->node->childNodes as $node) { + foreach ($this->domNode->childNodes as $node) { $this->value .= $node->wholeText; } } diff --git a/src/Symfony/Component/DomCrawler/Form.php b/src/Symfony/Component/DomCrawler/Form.php index 54ed5d9577459..dd7067eb8a04d 100644 --- a/src/Symfony/Component/DomCrawler/Form.php +++ b/src/Symfony/Component/DomCrawler/Form.php @@ -21,19 +21,19 @@ */ class Form extends Link implements \ArrayAccess { - private \DOMElement $button; + private \DOMElement|\DOM\Element $button; private FormFieldRegistry $fields; /** - * @param \DOMElement $node A \DOMElement instance - * @param string|null $currentUri The URI of the page where the form is embedded - * @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form) - * @param string|null $baseHref The URI of the used for relative links, but not for empty action + * @param \DOMElement|\DOM\Element $node A \DOMElement instance + * @param string|null $currentUri The URI of the page where the form is embedded + * @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form) + * @param string|null $baseHref The URI of the used for relative links, but not for empty action * * @throws \LogicException if the node is not a button inside a form tag */ public function __construct( - \DOMElement $node, + \DOMElement|\DOM\Element $node, ?string $currentUri = null, ?string $method = null, private ?string $baseHref = null, @@ -45,10 +45,26 @@ public function __construct( /** * Gets the form node associated with this form. + * + * @deprecated since Symfony 7.1, use `getFormDomNode()` instead */ public function getFormNode(): \DOMElement { - return $this->node; + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::getFormDomNode()" instead.', __METHOD__, __CLASS__); + + if ($this->domNode instanceof \DOM\Element) { + throw new \LogicException('The form node is not an instance of legacy \DOMElement.'); + } + + return $this->domNode; + } + + /** + * Gets the form node associated with this form. + */ + public function getFormDomNode(): \DOMElement|\DOM\Element + { + return $this->domNode; } /** @@ -200,11 +216,11 @@ public function getUri(): string protected function getRawUri(): string { // If the form was created from a button rather than the form node, check for HTML5 action overrides - if ($this->button !== $this->node && $this->button->getAttribute('formaction')) { + if ($this->button !== $this->domNode && $this->button->getAttribute('formaction')) { return $this->button->getAttribute('formaction'); } - return $this->node->getAttribute('action'); + return $this->domNode->getAttribute('action') ?? ''; } /** @@ -219,11 +235,11 @@ public function getMethod(): string } // If the form was created from a button rather than the form node, check for HTML5 method override - if ($this->button !== $this->node && $this->button->getAttribute('formmethod')) { + if ($this->button !== $this->domNode && $this->button->getAttribute('formmethod')) { return strtoupper($this->button->getAttribute('formmethod')); } - return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET'; + return $this->domNode->getAttribute('method') ? strtoupper($this->domNode->getAttribute('method') ?? '') : 'GET'; } /** @@ -233,7 +249,7 @@ public function getMethod(): string */ public function getName(): string { - return $this->node->getAttribute('name'); + return $this->domNode->getAttribute('name') ?? ''; } /** @@ -345,6 +361,16 @@ public function disableValidation(): static return $this; } + /** + * @deprecated since Symfony 7.1, use `setDomNode()` instead + */ + protected function setNode(\DOMElement $node): void + { + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::setDomNode()" instead.', __METHOD__, __CLASS__); + + $this->setDomNode($node); + } + /** * Sets the node for the form. * @@ -352,10 +378,12 @@ public function disableValidation(): static * * @throws \LogicException If given node is not a button or input or does not have a form ancestor */ - protected function setNode(\DOMElement $node): void + protected function setDomNode(\DOMElement|\DOM\Element $node): void { $this->button = $node; - if ('button' === $node->nodeName || ('input' === $node->nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) { + $nodeName = strtolower($node->nodeName); + + if ('button' === $nodeName || ('input' === $nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) { if ($node->hasAttribute('form')) { // if the node has the HTML5-compliant 'form' attribute, use it $formId = $node->getAttribute('form'); @@ -363,7 +391,7 @@ protected function setNode(\DOMElement $node): void if (null === $form) { throw new \LogicException(\sprintf('The selected node has an invalid form attribute (%s).', $formId)); } - $this->node = $form; + $this->domNode = $form; return; } @@ -372,12 +400,15 @@ protected function setNode(\DOMElement $node): void if (null === $node = $node->parentNode) { throw new \LogicException('The selected node does not have a form ancestor.'); } - } while ('form' !== $node->nodeName); - } elseif ('form' !== $node->nodeName) { - throw new \LogicException(\sprintf('Unable to submit on a "%s" tag.', $node->nodeName)); + } while ('form' !== strtolower($node->nodeName)); + } elseif ('form' !== $nodeName) { + throw new \LogicException(\sprintf('Unable to submit on a "%s" tag.', $nodeName)); } - $this->node = $node; + $this->domNode = $node; + if ($node instanceof \DOMElement) { + $this->node = $node; + } } /** @@ -391,11 +422,16 @@ private function initialize(): void { $this->fields = new FormFieldRegistry(); - $xpath = new \DOMXPath($this->node->ownerDocument); + if ($this->domNode->ownerDocument instanceof \DOM\Document) { + $xpath = new \DOM\XPath($this->domNode->ownerDocument); + } else { + $xpath = new \DOMXPath($this->domNode->ownerDocument); + } + $buttonNodeName = strtolower($this->button->nodeName); // add submitted button if it has a valid name - if ('form' !== $this->button->nodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) { - if ('input' == $this->button->nodeName && 'image' == strtolower($this->button->getAttribute('type'))) { + if ('form' !== $buttonNodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) { + if ('input' == $buttonNodeName && 'image' == strtolower($this->button->getAttribute('type') ?? '')) { $name = $this->button->getAttribute('name'); $this->button->setAttribute('value', '0'); @@ -415,33 +451,33 @@ private function initialize(): void } // find form elements corresponding to the current form - if ($this->node->hasAttribute('id')) { + if ($this->domNode->hasAttribute('id')) { // corresponding elements are either descendants or have a matching HTML5 form attribute - $formId = Crawler::xpathLiteral($this->node->getAttribute('id')); + $formId = DomCrawler::xpathLiteral($this->domNode->getAttribute('id') ?? ''); $fieldNodes = $xpath->query(\sprintf('( descendant::input[@form=%s] | descendant::button[@form=%1$s] | descendant::textarea[@form=%1$s] | descendant::select[@form=%1$s] | //form[@id=%1$s]//input[not(@form)] | //form[@id=%1$s]//button[not(@form)] | //form[@id=%1$s]//textarea[not(@form)] | //form[@id=%1$s]//select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $formId)); } else { - // do the xpath query with $this->node as the context node, to only find descendant elements + // do the xpath query with $this->domNode as the context node, to only find descendant elements // however, descendant elements with form attribute are not part of this form - $fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $this->node); + $fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $this->domNode); } foreach ($fieldNodes as $node) { $this->addField($node); } - if ($this->baseHref && '' !== $this->node->getAttribute('action')) { + if ($this->baseHref && '' !== ($this->domNode->getAttribute('action') ?? '')) { $this->currentUri = $this->baseHref; } } - private function addField(\DOMElement $node): void + private function addField(\DOMElement|\DOM\Element $node): void { if (!$node->hasAttribute('name') || !$node->getAttribute('name')) { return; } - $nodeName = $node->nodeName; + $nodeName = strtolower($node->nodeName); if ('select' == $nodeName || 'input' == $nodeName && 'checkbox' == strtolower($node->getAttribute('type'))) { $this->set(new ChoiceFormField($node)); } elseif ('input' == $nodeName && 'radio' == strtolower($node->getAttribute('type'))) { @@ -452,9 +488,9 @@ private function addField(\DOMElement $node): void } else { $this->set(new ChoiceFormField($node)); } - } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type'))) { + } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type') ?? '')) { $this->set(new Field\FileFormField($node)); - } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image'])) { + } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type') ?? ''), ['submit', 'button', 'image'])) { $this->set(new Field\InputFormField($node)); } elseif ('textarea' == $nodeName) { $this->set(new Field\TextareaFormField($node)); diff --git a/src/Symfony/Component/DomCrawler/Image.php b/src/Symfony/Component/DomCrawler/Image.php index 964b9788ccab7..5fd229808eba7 100644 --- a/src/Symfony/Component/DomCrawler/Image.php +++ b/src/Symfony/Component/DomCrawler/Image.php @@ -16,22 +16,35 @@ */ class Image extends AbstractUriElement { - public function __construct(\DOMElement $node, ?string $currentUri = null) - { - parent::__construct($node, $currentUri, 'GET'); - } - protected function getRawUri(): string { - return $this->node->getAttribute('src'); + return $this->domNode->getAttribute('src') ?? ''; } + /** + * @deprecated since Symfony 7.1, use `setDomNode()` instead + */ protected function setNode(\DOMElement $node): void { if ('img' !== $node->nodeName) { throw new \LogicException(\sprintf('Unable to visualize a "%s" tag.', $node->nodeName)); } - $this->node = $node; + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::setDomNode()" instead.', __METHOD__, __CLASS__); + + $this->setDomNode($node); + } + + protected function setDomNode(\DOMElement|\DOM\Element $node): void + { + $nodeName = strtolower($node->nodeName); + if ('img' !== $nodeName) { + throw new \LogicException(sprintf('Unable to visualize a "%s" tag.', $nodeName)); + } + + $this->domNode = $node; + if ($node instanceof \DOMElement) { + $this->node = $node; + } } } diff --git a/src/Symfony/Component/DomCrawler/Link.php b/src/Symfony/Component/DomCrawler/Link.php index da08de4014303..0941b543935b8 100644 --- a/src/Symfony/Component/DomCrawler/Link.php +++ b/src/Symfony/Component/DomCrawler/Link.php @@ -20,15 +20,29 @@ class Link extends AbstractUriElement { protected function getRawUri(): string { - return $this->node->getAttribute('href'); + return $this->domNode->getAttribute('href') ?? ''; } + /** + * @deprecated since Symfony 7.1, use `setDomNode()` instead + */ protected function setNode(\DOMElement $node): void { - if ('a' !== $node->nodeName && 'area' !== $node->nodeName && 'link' !== $node->nodeName) { + trigger_deprecation('symfony/dom-crawler', '7.1', 'The "%s()" method is deprecated, use "%s::setDomNode()" instead.', __METHOD__, __CLASS__); + + $this->setDomNode($node); + } + + protected function setDomNode(\DOMElement|\DOM\Element $node): void + { + $nodeName = strtolower($node->nodeName); + if ('a' !== $nodeName && 'area' !== $nodeName && 'link' !== $nodeName) { throw new \LogicException(\sprintf('Unable to navigate from a "%s" tag.', $node->nodeName)); } - $this->node = $node; + $this->domNode = $node; + if ($node instanceof \DOMElement) { + $this->node = $node; + } } } diff --git a/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php b/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php index 97b16b9fe6073..7f243484227fa 100644 --- a/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php +++ b/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php @@ -26,6 +26,11 @@ protected function createCrawler($node = null, ?string $uri = null, ?string $bas return new Crawler($node, $uri, $baseHref, $useHtml5Parser); } + protected static function getCrawlerClass(): string + { + return Crawler::class; + } + public function testConstructor() { $crawler = $this->createCrawler(); @@ -251,7 +256,7 @@ public function testEq() { $crawler = $this->createTestCrawler()->filterXPath('//li'); $this->assertNotSame($crawler, $crawler->eq(0), '->eq() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->eq(0), '->eq() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->eq(0), '->eq() returns a new instance of a crawler'); $this->assertEquals('Two', $crawler->eq(1)->text(), '->eq() returns the nth node of the list'); $this->assertCount(0, $crawler->eq(100), '->eq() returns an empty crawler if the nth node does not exist'); @@ -283,7 +288,7 @@ public function testSlice() { $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); $this->assertNotSame($crawler->slice(), $crawler, '->slice() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->slice(), '->slice() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->slice(), '->slice() returns a new instance of a crawler'); $this->assertCount(3, $crawler->slice(), '->slice() does not slice the nodes in the list if any param is entered'); $this->assertCount(1, $crawler->slice(1, 1), '->slice() slices the nodes in the list'); @@ -294,7 +299,7 @@ public function testReduce() $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); $nodes = $crawler->reduce(fn ($node, $i) => 1 !== $i); $this->assertNotSame($nodes, $crawler, '->reduce() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $nodes, '->reduce() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $nodes, '->reduce() returns a new instance of a crawler'); $this->assertCount(2, $nodes, '->reduce() filters the nodes in the list'); } @@ -471,7 +476,7 @@ public function testFilterXPath() { $crawler = $this->createTestCrawler(); $this->assertNotSame($crawler, $crawler->filterXPath('//li'), '->filterXPath() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->filterXPath('//li'), '->filterXPath() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->filterXPath('//li'), '->filterXPath() returns a new instance of a crawler'); $crawler = $this->createTestCrawler()->filterXPath('//ul'); $this->assertCount(6, $crawler->filterXPath('//li'), '->filterXPath() filters the node list with the XPath expression'); @@ -638,7 +643,7 @@ public function testFilter() { $crawler = $this->createTestCrawler(); $this->assertNotSame($crawler, $crawler->filter('li'), '->filter() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->filter('li'), '->filter() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->filter('li'), '->filter() returns a new instance of a crawler'); $crawler = $this->createTestCrawler()->filter('ul'); @@ -691,7 +696,7 @@ public function testSelectLink() { $crawler = $this->createTestCrawler(); $this->assertNotSame($crawler, $crawler->selectLink('Foo'), '->selectLink() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->selectLink('Foo'), '->selectLink() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectLink('Foo'), '->selectLink() returns a new instance of a crawler'); $this->assertCount(1, $crawler->selectLink('Fabien\'s Foo'), '->selectLink() selects links by the node values'); $this->assertCount(1, $crawler->selectLink('Fabien\'s Bar'), '->selectLink() selects links by the alt attribute of a clickable image'); @@ -710,7 +715,7 @@ public function testSelectImage() { $crawler = $this->createTestCrawler(); $this->assertNotSame($crawler, $crawler->selectImage('Bar'), '->selectImage() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->selectImage('Bar'), '->selectImage() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectImage('Bar'), '->selectImage() returns a new instance of a crawler'); $this->assertCount(1, $crawler->selectImage('Fabien\'s Bar'), '->selectImage() selects images by alt attribute'); $this->assertCount(2, $crawler->selectImage('Fabien"s Bar'), '->selectImage() selects images by alt attribute'); @@ -721,7 +726,7 @@ public function testSelectButton() { $crawler = $this->createTestCrawler(); $this->assertNotSame($crawler, $crawler->selectButton('FooValue'), '->selectButton() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->selectButton('FooValue'), '->selectButton() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectButton('FooValue'), '->selectButton() returns a new instance of a crawler'); $this->assertEquals(1, $crawler->selectButton('FooValue')->count(), '->selectButton() selects buttons'); $this->assertEquals(1, $crawler->selectButton('FooName')->count(), '->selectButton() selects buttons'); @@ -891,7 +896,7 @@ public function testForm() $this->assertInstanceOf(Form::class, $crawler->form(), '->form() returns a Form instance'); $this->assertInstanceOf(Form::class, $crawler2->form(), '->form() returns a Form instance'); - $this->assertEquals($crawler->form()->getFormNode()->getAttribute('id'), $crawler2->form()->getFormNode()->getAttribute('id'), '->form() works on elements with form attribute'); + $this->assertEquals($crawler->form()->getFormDomNode()->getAttribute('id'), $crawler2->form()->getFormDomNode()->getAttribute('id'), '->form() works on elements with form attribute'); $this->assertEquals(['FooName' => 'FooBar', 'TextName' => 'TextValue', 'FooTextName' => 'FooTextValue'], $crawler->form(['FooName' => 'FooBar'])->getValues(), '->form() takes an array of values to submit as its first argument'); $this->assertEquals(['FooName' => 'FooValue', 'TextName' => 'TextValue', 'FooTextName' => 'FooTextValue'], $crawler->form()->getValues(), '->getValues() returns correct form values'); @@ -917,7 +922,7 @@ public function testLast() { $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); $this->assertNotSame($crawler, $crawler->last(), '->last() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->last(), '->last() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->last(), '->last() returns a new instance of a crawler'); $this->assertEquals('Three', $crawler->last()->text()); } @@ -926,7 +931,7 @@ public function testFirst() { $crawler = $this->createTestCrawler()->filterXPath('//li'); $this->assertNotSame($crawler, $crawler->first(), '->first() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->first(), '->first() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->first(), '->first() returns a new instance of a crawler'); $this->assertEquals('One', $crawler->first()->text()); } @@ -935,7 +940,7 @@ public function testSiblings() { $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(1); $this->assertNotSame($crawler, $crawler->siblings(), '->siblings() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->siblings(), '->siblings() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->siblings(), '->siblings() returns a new instance of a crawler'); $nodes = $crawler->siblings(); $this->assertEquals(2, $nodes->count()); @@ -1013,15 +1018,15 @@ public function testClosest() $foo = $crawler->filter('#foo'); $newFoo = $foo->closest('#foo'); - $this->assertInstanceOf(Crawler::class, $newFoo); + $this->assertInstanceOf(static::getCrawlerClass(), $newFoo); $this->assertSame('newFoo ok', $newFoo->attr('class')); $lorem1 = $foo->closest('.lorem1'); - $this->assertInstanceOf(Crawler::class, $lorem1); + $this->assertInstanceOf(static::getCrawlerClass(), $lorem1); $this->assertSame('lorem1 ok', $lorem1->attr('class')); $lorem2 = $foo->closest('.lorem2'); - $this->assertInstanceOf(Crawler::class, $lorem2); + $this->assertInstanceOf(static::getCrawlerClass(), $lorem2); $this->assertSame('lorem2 ok', $lorem2->attr('class')); $lorem3 = $foo->closest('.lorem3'); @@ -1058,7 +1063,7 @@ public function testNextAll() { $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(1); $this->assertNotSame($crawler, $crawler->nextAll(), '->nextAll() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->nextAll(), '->nextAll() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->nextAll(), '->nextAll() returns a new instance of a crawler'); $nodes = $crawler->nextAll(); $this->assertEquals(1, $nodes->count()); @@ -1076,7 +1081,7 @@ public function testPreviousAll() { $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(2); $this->assertNotSame($crawler, $crawler->previousAll(), '->previousAll() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->previousAll(), '->previousAll() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->previousAll(), '->previousAll() returns a new instance of a crawler'); $nodes = $crawler->previousAll(); $this->assertEquals(2, $nodes->count()); @@ -1094,7 +1099,7 @@ public function testChildren() { $crawler = $this->createTestCrawler()->filterXPath('//ul'); $this->assertNotSame($crawler, $crawler->children(), '->children() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $crawler->children(), '->children() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->children(), '->children() returns a new instance of a crawler'); $nodes = $crawler->children(); $this->assertEquals(3, $nodes->count()); @@ -1155,7 +1160,7 @@ public function testAncestors() $nodes = $crawler->ancestors(); $this->assertNotSame($crawler, $nodes, '->ancestors() returns a new instance of a crawler'); - $this->assertInstanceOf(Crawler::class, $nodes, '->ancestors() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $nodes, '->ancestors() returns a new instance of a crawler'); $this->assertEquals(3, $crawler->ancestors()->count()); @@ -1250,7 +1255,7 @@ public function testEvaluateReturnsACrawlerIfXPathExpressionEvaluatesToANode() { $crawler = $this->createTestCrawler()->evaluate('//form/input[1]'); - $this->assertInstanceOf(Crawler::class, $crawler); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler); $this->assertCount(1, $crawler); $this->assertSame('input', $crawler->first()->nodeName()); } diff --git a/src/Symfony/Component/DomCrawler/Tests/DomCrawlerTest.php b/src/Symfony/Component/DomCrawler/Tests/DomCrawlerTest.php new file mode 100644 index 0000000000000..ce80c40ce67f4 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/DomCrawlerTest.php @@ -0,0 +1,1399 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DomCrawler\DomCrawler; +use Symfony\Component\DomCrawler\Form; +use Symfony\Component\DomCrawler\Image; +use Symfony\Component\DomCrawler\Link; + +/** + * @requires PHP 8.4 + */ +class DomCrawlerTest extends TestCase +{ + public static function getDoctype(): string + { + return ''; + } + + protected function createCrawler($node = null, ?string $uri = null, ?string $baseHref = null) + { + return new DomCrawler($node, $uri, $baseHref, DomCrawler::CRAWLER_DISABLE_DEFAULT_NAMESPACE); + } + + protected static function getCrawlerClass(): string + { + return DomCrawler::class; + } + + public function testConstructorWithModernNode() + { + $crawler = $this->createCrawler(); + $this->assertCount(0, $crawler, '__construct() returns an empty crawler'); + + $doc = \DOM\HTMLDocument::createEmpty(); + $node = $doc->createElement('test'); + + $crawler = $this->createCrawler($node); + $this->assertCount(1, $crawler, '__construct() takes a node as a first argument'); + } + + public function testClearWithModerNode() + { + $doc = \DOM\HTMLDocument::createEmpty(); + $node = $doc->createElement('test'); + + $crawler = $this->createCrawler($node); + $crawler->clear(); + $this->assertCount(0, $crawler, '->clear() removes all the nodes from the crawler'); + } + + + public function testConstructor() + { + $crawler = $this->createCrawler(); + $this->assertCount(0, $crawler, '__construct() returns an empty crawler'); + + $doc = \DOM\XMLDocument::createEmpty(); + $node = $doc->createElement('test'); + + $crawler = $this->createCrawler($node); + $this->assertCount(1, $crawler, '__construct() takes a node as a first argument'); + } + + public function testGetUri() + { + $uri = 'http://symfony.com'; + $crawler = $this->createCrawler(null, $uri); + $this->assertEquals($uri, $crawler->getUri()); + } + + public function testGetBaseHref() + { + $baseHref = 'http://symfony.com'; + $crawler = $this->createCrawler(null, null, $baseHref); + $this->assertEquals($baseHref, $crawler->getBaseHref()); + } + + public function testAdd() + { + $crawler = $this->createCrawler(); + $crawler->add($this->createDomDocument()); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->add() adds nodes from a \DOM\Document'); + + $crawler = $this->createCrawler(); + $crawler->add($this->createNodeList()); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->add() adds nodes from a \DOMNodeList'); + + $list = []; + foreach ($this->createNodeList() as $node) { + $list[] = $node; + } + $crawler = $this->createCrawler(); + $crawler->add($list); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->add() adds nodes from an array of nodes'); + + $crawler = $this->createCrawler(); + $crawler->add($this->createNodeList()->item(0)); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->add() adds nodes from a \DOMNode'); + + $crawler = $this->createCrawler(); + $crawler->add($this->getDoctype().'Foo'); + $this->assertEquals('Foo', $crawler->filterXPath('//body')->text(), '->add() adds nodes from a string'); + } + + public function testAddMultipleDocumentNode() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Attaching DOM nodes from multiple documents in the same crawler is forbidden.'); + $crawler = $this->createTestCrawler(); + $crawler->addHtmlContent($this->getDoctype().'
', 'UTF-8'); + } + + public function testAddHtmlContent() + { + $crawler = $this->createCrawler(); + $crawler->addHtmlContent($this->getDoctype().'
', 'UTF-8'); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addHtmlContent() adds nodes from an HTML string'); + } + + public function testAddHtmlContentWithBaseTag() + { + $crawler = $this->createCrawler(); + $crawler->addHtmlContent($this->getDoctype().'', 'UTF-8'); + + $this->assertEquals('http://symfony.com', $crawler->filterXPath('//base')->attr('href'), '->addHtmlContent() adds nodes from an HTML string'); + $this->assertEquals('http://symfony.com/contact', $crawler->filterXPath('//a')->link()->getUri(), '->addHtmlContent() adds nodes from an HTML string'); + } + + /** + * @requires extension mbstring + */ + public function testAddHtmlContentCharset() + { + $crawler = $this->createCrawler(); + $crawler->addHtmlContent($this->getDoctype().'
Tiếng Việt', 'UTF-8'); + + $this->assertEquals('Tiếng Việt', $crawler->filterXPath('//div')->text()); + } + + public function testAddHtmlContentInvalidBaseTag() + { + $crawler = $this->createCrawler(null, 'http://symfony.com'); + $crawler->addHtmlContent($this->getDoctype().'', 'UTF-8'); + + $this->assertEquals('http://symfony.com/contact', current($crawler->filterXPath('//a')->links())->getUri(), '->addHtmlContent() correctly handles a non-existent base tag href attribute'); + } + + /** + * @requires extension mbstring + */ + public function testAddHtmlContentCharsetGbk() + { + $crawler = $this->createCrawler(); + // gbk encode of

中文

+ $crawler->addHtmlContent($this->getDoctype().base64_decode('PGh0bWw+PHA+1tDOxDwvcD48L2h0bWw+'), 'gbk'); + + $this->assertEquals('中文', $crawler->filterXPath('//p')->text()); + } + + public function testAddXmlContent() + { + $crawler = $this->createCrawler(); + $crawler->addXmlContent($this->getDoctype().'
', 'UTF-8'); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addXmlContent() adds nodes from an XML string'); + } + + public function testAddXmlContentCharset() + { + $crawler = $this->createCrawler(); + $crawler->addXmlContent($this->getDoctype().'
Tiếng Việt
', 'UTF-8'); + + $this->assertEquals('Tiếng Việt', $crawler->filterXPath('//div')->text()); + } + + public function testAddContent() + { + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
', 'text/html; charset=UTF-8'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() adds nodes from an HTML string'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
', 'text/html; charset=UTF-8; dir=RTL'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() adds nodes from an HTML string with extended content type'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() uses text/html as the default type'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
', 'text/xml; charset=UTF-8'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() adds nodes from an XML string'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
', 'text/xml'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() adds nodes from an XML string'); + + $crawler = $this->createCrawler(); + $crawler->addContent('foo bar', 'text/plain'); + $this->assertCount(0, $crawler, '->addContent() does nothing if the type is not (x|ht)ml'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'中文'); + $this->assertEquals('中文', $crawler->filterXPath('//span')->text(), '->addContent() guess wrong charset'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
'); + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addContent() ignores bad charset'); + + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'', 'text/html; charset=UTF-8'); + $this->assertEquals('var foo = "bär";', $crawler->filterXPath('//script')->text(), '->addContent() does not interfere with script content'); + } + + /** + * @requires extension iconv + */ + public function testAddContentNonUtf8() + { + $crawler = $this->createCrawler(); + $crawler->addContent(iconv('UTF-8', 'SJIS', $this->getDoctype().'日本語')); + $this->assertEquals('日本語', $crawler->filterXPath('//body')->text(), '->addContent() can recognize "Shift_JIS" in html5 meta charset tag'); + } + + public function testAddDocument() + { + $crawler = $this->createCrawler(); + $crawler->addDocument($this->createDomDocument()); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addDocument() adds nodes from a \DOM\Document'); + } + + public function testAddNodeList() + { + $crawler = $this->createCrawler(); + $crawler->addNodeList($this->createNodeList()); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addNodeList() adds nodes from a \DOMNodeList'); + } + + public function testAddNodes() + { + $list = []; + foreach ($this->createNodeList() as $node) { + $list[] = $node; + } + + $crawler = $this->createCrawler(); + $crawler->addNodes($list); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addNodes() adds nodes from an array of nodes'); + } + + public function testAddNode() + { + $crawler = $this->createCrawler(); + $crawler->addNode($this->createNodeList()->item(0)); + + $this->assertEquals('foo', $crawler->filterXPath('//div')->attr('class'), '->addNode() adds nodes from a \DOMNode'); + } + + public function testClear() + { + $doc = \DOM\XMLDocument::createEmpty(); + $node = $doc->createElement('test'); + + $crawler = $this->createCrawler($node); + $crawler->clear(); + $this->assertCount(0, $crawler, '->clear() removes all the nodes from the crawler'); + } + + public function testEq() + { + $crawler = $this->createTestCrawler()->filterXPath('//li'); + $this->assertNotSame($crawler, $crawler->eq(0), '->eq() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->eq(0), '->eq() returns a new instance of a crawler'); + + $this->assertEquals('Two', $crawler->eq(1)->text(), '->eq() returns the nth node of the list'); + $this->assertCount(0, $crawler->eq(100), '->eq() returns an empty crawler if the nth node does not exist'); + } + + public function testNormalizeWhiteSpace() + { + $crawler = $this->createTestCrawler()->filterXPath('//p'); + $this->assertSame('Elsa <3', $crawler->text(null, true), '->text(null, true) returns the text with normalized whitespace'); + $this->assertNotSame('Elsa <3', $crawler->text(null, false)); + } + + public function testEach() + { + $data = $this->createTestCrawler()->filterXPath('//ul[1]/li')->each(fn ($node, $i) => $i.'-'.$node->text()); + + $this->assertEquals(['0-One', '1-Two', '2-Three'], $data, '->each() executes an anonymous function on each node of the list'); + } + + public function testIteration() + { + $crawler = $this->createTestCrawler()->filterXPath('//li'); + + $this->assertInstanceOf(\Traversable::class, $crawler); + $this->assertContainsOnlyInstancesOf(\DOM\Element::class, iterator_to_array($crawler), 'Iterating a Crawler gives DOMElement instances'); + } + + public function testSlice() + { + $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); + $this->assertNotSame($crawler->slice(), $crawler, '->slice() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->slice(), '->slice() returns a new instance of a crawler'); + + $this->assertCount(3, $crawler->slice(), '->slice() does not slice the nodes in the list if any param is entered'); + $this->assertCount(1, $crawler->slice(1, 1), '->slice() slices the nodes in the list'); + } + + public function testReduce() + { + $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); + $nodes = $crawler->reduce(fn ($node, $i) => 1 !== $i); + $this->assertNotSame($nodes, $crawler, '->reduce() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $nodes, '->reduce() returns a new instance of a crawler'); + + $this->assertCount(2, $nodes, '->reduce() filters the nodes in the list'); + } + + public function testAttr() + { + $this->assertEquals('first', $this->createTestCrawler()->filterXPath('//li')->attr('class'), '->attr() returns the attribute of the first element of the node list'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->attr('class'); + $this->fail('->attr() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->attr() throws an \InvalidArgumentException if the node list is empty'); + } + + $this->assertSame('my value', $this->createTestCrawler()->filterXPath('//notexists')->attr('class', 'my value')); + $this->assertSame('my value', $this->createTestCrawler()->filterXPath('//li')->attr('attr-not-exists', 'my value')); + } + + public function testMissingAttrValueIsNull() + { + $crawler = $this->createCrawler(); + $crawler->addContent($this->getDoctype().'
', 'text/html; charset=UTF-8'); + $div = $crawler->filterXPath('//div'); + + $this->assertEquals('sample value', $div->attr('non-empty-attr'), '->attr() reads non-empty attributes correctly'); + $this->assertEquals('', $div->attr('empty-attr'), '->attr() reads empty attributes correctly'); + $this->assertNull($div->attr('missing-attr'), '->attr() reads missing attributes correctly'); + } + + public function testNodeName() + { + $this->assertEquals('li', $this->createTestCrawler()->filterXPath('//li')->nodeName(), '->nodeName() returns the node name of the first element of the node list'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->nodeName(); + $this->fail('->nodeName() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->nodeName() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testText() + { + $this->assertEquals('One', $this->createTestCrawler()->filterXPath('//li')->text(), '->text() returns the node value of the first element of the node list'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->text(); + $this->fail('->text() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->text() throws an \InvalidArgumentException if the node list is empty'); + } + + $this->assertSame('my value', $this->createTestCrawler(null)->filterXPath('//ol')->text('my value')); + } + + public static function provideInnerTextExamples() + { + return [ + [ + '//*[@id="complex-elements"]/*[@class="one"]', // XPath query + 'Parent text Child text', // Result of Crawler::text() + 'Parent text', // Result of Crawler::innerText() + ' Parent text ', // Result of Crawler::innerText(false) + ], + [ + '//*[@id="complex-elements"]/*[@class="two"]', + 'Child text Parent text', + 'Parent text', + ' ', + ], + [ + '//*[@id="complex-elements"]/*[@class="three"]', + 'Parent text Child text Parent text', + 'Parent text', + ' Parent text ', + ], + [ + '//*[@id="complex-elements"]/*[@class="four"]', + 'Child text', + '', + ' ', + ], + [ + '//*[@id="complex-elements"]/*[@class="five"]', + 'Child text Another child', + '', + ' ', + ], + [ + '//*[@id="complex-elements"]/*[@class="six"]', + 'console.log("Test JavaScript content");', + 'console.log("Test JavaScript content");', + ' console.log("Test JavaScript content"); ', + ], + ]; + } + + /** + * @dataProvider provideInnerTextExamples + */ + public function testInnerText( + string $xPathQuery, + string $expectedText, + string $expectedInnerText, + string $expectedInnerTextNormalizeWhitespaceFalse, + ) { + self::assertCount(1, $crawler = $this->createTestCrawler()->filterXPath($xPathQuery)); + + self::assertSame($expectedText, $crawler->text()); + self::assertSame($expectedInnerText, $crawler->innerText()); + self::assertSame($expectedInnerTextNormalizeWhitespaceFalse, $crawler->innerText(false)); + } + + public function testHtml() + { + $this->assertEquals('Bar', $this->createTestCrawler()->filterXPath('//a[5]')->html()); + $this->assertEquals('', trim(preg_replace('~>\s+<~', '><', $this->createTestCrawler()->filterXPath('//form[@id="FooFormId"]')->html()))); + + try { + $this->createTestCrawler()->filterXPath('//ol')->html(); + $this->fail('->html() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->html() throws an \InvalidArgumentException if the node list is empty'); + } + + $this->assertSame('my value', $this->createTestCrawler(null)->filterXPath('//ol')->html('my value')); + } + + public function testEmojis() + { + $crawler = $this->createCrawler($this->getDoctype().'

Hey 👋

'); + + $this->assertSame('

Hey 👋

', $crawler->html()); + } + + public function testExtract() + { + $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); + + $this->assertEquals(['One', 'Two', 'Three'], $crawler->extract(['_text']), '->extract() returns an array of extracted data from the node list'); + $this->assertEquals([['One', 'first'], ['Two', ''], ['Three', '']], $crawler->extract(['_text', 'class']), '->extract() returns an array of extracted data from the node list'); + $this->assertEquals([[], [], []], $crawler->extract([]), '->extract() returns empty arrays if the attribute list is empty'); + + $this->assertEquals([], $this->createTestCrawler()->filterXPath('//ol')->extract(['_text']), '->extract() returns an empty array if the node list is empty'); + + $this->assertEquals([['One', 'li'], ['Two', 'li'], ['Three', 'li']], $crawler->extract(['_text', '_name']), '->extract() returns an array of extracted data from the node list'); + } + + public function testFilterXpathComplexQueries() + { + $crawler = $this->createTestCrawler()->filterXPath('//body'); + + $this->assertCount(0, $crawler->filterXPath('/input')); + $this->assertCount(0, $crawler->filterXPath('/body')); + $this->assertCount(1, $crawler->filterXPath('./body')); + $this->assertCount(1, $crawler->filterXPath('.//body')); + $this->assertCount(5, $crawler->filterXPath('.//input')); + $this->assertCount(4, $crawler->filterXPath('//form')->filterXPath('//button | //input')); + $this->assertCount(1, $crawler->filterXPath('body')); + $this->assertCount(6, $crawler->filterXPath('//button | //input')); + $this->assertCount(1, $crawler->filterXPath('//body')); + $this->assertCount(1, $crawler->filterXPath('descendant-or-self::body')); + $this->assertCount(1, $crawler->filterXPath('//div[@id="parent"]')->filterXPath('./div'), 'A child selection finds only the current div'); + $this->assertCount(3, $crawler->filterXPath('//div[@id="parent"]')->filterXPath('descendant::div'), 'A descendant selector matches the current div and its child'); + $this->assertCount(3, $crawler->filterXPath('//div[@id="parent"]')->filterXPath('//div'), 'A descendant selector matches the current div and its child'); + $this->assertCount(5, $crawler->filterXPath('(//a | //div)//img')); + $this->assertCount(7, $crawler->filterXPath('((//a | //div)//img | //ul)')); + $this->assertCount(7, $crawler->filterXPath('( ( //a | //div )//img | //ul )')); + $this->assertCount(1, $crawler->filterXPath("//a[./@href][((./@id = 'Klausi|Claudiu' or normalize-space(string(.)) = 'Klausi|Claudiu' or ./@title = 'Klausi|Claudiu' or ./@rel = 'Klausi|Claudiu') or .//img[./@alt = 'Klausi|Claudiu'])]")); + } + + public function testFilterXPath() + { + $crawler = $this->createTestCrawler(); + $this->assertNotSame($crawler, $crawler->filterXPath('//li'), '->filterXPath() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->filterXPath('//li'), '->filterXPath() returns a new instance of a crawler'); + + $crawler = $this->createTestCrawler()->filterXPath('//ul'); + $this->assertCount(6, $crawler->filterXPath('//li'), '->filterXPath() filters the node list with the XPath expression'); + + $crawler = $this->createTestCrawler(); + $this->assertCount(3, $crawler->filterXPath('//body')->filterXPath('//button')->ancestors(), '->filterXpath() preserves ancestors when chained'); + } + + public function testFilterRemovesDuplicates() + { + $crawler = $this->createTestCrawler()->filter('html, body')->filter('li'); + $this->assertCount(6, $crawler, 'The crawler removes duplicates when filtering.'); + } + + public function testFilterXPathWithDefaultNamespace() + { + $crawler = $this->createTestXmlCrawler()->filterXPath('//default:entry/default:id'); + $this->assertCount(1, $crawler, '->filterXPath() automatically registers a namespace'); + $this->assertSame('tag:youtube.com,2008:video:kgZRZmEc9j4', $crawler->text()); + } + + public function testFilterXPathWithCustomDefaultNamespace() + { + $crawler = $this->createTestXmlCrawler(); + $crawler->setDefaultNamespacePrefix('x'); + $crawler = $crawler->filterXPath('//x:entry/x:id'); + + $this->assertCount(1, $crawler, '->filterXPath() lets to override the default namespace prefix'); + $this->assertSame('tag:youtube.com,2008:video:kgZRZmEc9j4', $crawler->text()); + } + + public function testFilterXPathWithNamespace() + { + $crawler = $this->createTestXmlCrawler()->filterXPath('//yt:accessControl'); + $this->assertCount(2, $crawler, '->filterXPath() automatically registers a namespace'); + } + + public function testFilterXPathWithMultipleNamespaces() + { + $crawler = $this->createTestXmlCrawler()->filterXPath('//media:group/yt:aspectRatio'); + $this->assertCount(1, $crawler, '->filterXPath() automatically registers multiple namespaces'); + $this->assertSame('widescreen', $crawler->text()); + } + + public function testFilterXPathWithManuallyRegisteredNamespace() + { + $crawler = $this->createTestXmlCrawler(); + $crawler->registerNamespace('m', 'http://search.yahoo.com/mrss/'); + + $crawler = $crawler->filterXPath('//m:group/yt:aspectRatio'); + $this->assertCount(1, $crawler, '->filterXPath() uses manually registered namespace'); + $this->assertSame('widescreen', $crawler->text()); + } + + public function testFilterXPathWithAnUrl() + { + $crawler = $this->createTestXmlCrawler(); + + $crawler = $crawler->filterXPath('//media:category[@scheme="http://gdata.youtube.com/schemas/2007/categories.cat"]'); + $this->assertCount(1, $crawler); + $this->assertSame('Music', $crawler->text()); + } + + public function testFilterXPathWithFakeRoot() + { + $crawler = $this->createTestCrawler(); + $this->assertCount(0, $crawler->filterXPath('.'), '->filterXPath() returns an empty result if the XPath references the fake root node'); + $this->assertCount(0, $crawler->filterXPath('self::*'), '->filterXPath() returns an empty result if the XPath references the fake root node'); + $this->assertCount(0, $crawler->filterXPath('self::_root'), '->filterXPath() returns an empty result if the XPath references the fake root node'); + } + + public function testFilterXPathWithAncestorAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//form'); + + $this->assertCount(0, $crawler->filterXPath('ancestor::*'), 'The fake root node has no ancestor nodes'); + } + + public function testFilterXPathWithAncestorOrSelfAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//form'); + + $this->assertCount(0, $crawler->filterXPath('ancestor-or-self::*'), 'The fake root node has no ancestor nodes'); + } + + public function testFilterXPathWithAttributeAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//form'); + + $this->assertCount(0, $crawler->filterXPath('attribute::*'), 'The fake root node has no attribute nodes'); + } + + public function testFilterXPathWithAttributeAxisAfterElementAxis() + { + $this->assertCount(3, $this->createTestCrawler()->filterXPath('//form/button/attribute::*'), '->filterXPath() handles attribute axes properly when they are preceded by an element filtering axis'); + } + + public function testFilterXPathWithChildAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//div[@id="parent"]'); + + $this->assertCount(1, $crawler->filterXPath('child::div'), 'A child selection finds only the current div'); + } + + public function testFilterXPathWithFollowingAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//a'); + + $this->assertCount(0, $crawler->filterXPath('following::div'), 'The fake root node has no following nodes'); + } + + public function testFilterXPathWithFollowingSiblingAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//a'); + + $this->assertCount(0, $crawler->filterXPath('following-sibling::div'), 'The fake root node has no following nodes'); + } + + public function testFilterXPathWithNamespaceAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//button'); + + $this->assertCount(0, $crawler->filterXPath('namespace::*'), 'The fake root node has no namespace nodes'); + } + + public function testFilterXPathWithNamespaceAxisThrows() + { + $this->expectException(\DOMException::class); + $this->expectExceptionMessage('The namespace axis is not well-defined in the living DOM specification. Use Dom\Element::getInScopeNamespaces() or Dom\Element::getDescendantNamespaces() instead.'); + + $this->createTestCrawler()->filterXPath('//div[@id="parent"]/namespace::*'); + } + + public function testFilterXPathWithParentAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//button'); + + $this->assertCount(0, $crawler->filterXPath('parent::*'), 'The fake root node has no parent nodes'); + } + + public function testFilterXPathWithPrecedingAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//form'); + + $this->assertCount(0, $crawler->filterXPath('preceding::*'), 'The fake root node has no preceding nodes'); + } + + public function testFilterXPathWithPrecedingSiblingAxis() + { + $crawler = $this->createTestCrawler()->filterXPath('//form'); + + $this->assertCount(0, $crawler->filterXPath('preceding-sibling::*'), 'The fake root node has no preceding nodes'); + } + + public function testFilterXPathWithSelfAxes() + { + $crawler = $this->createTestCrawler()->filterXPath('//a'); + + $this->assertCount(0, $crawler->filterXPath('self::a'), 'The fake root node has no "real" element name'); + $this->assertCount(0, $crawler->filterXPath('self::a/img'), 'The fake root node has no "real" element name'); + $this->assertCount(10, $crawler->filterXPath('self::*/a')); + } + + public function testFilter() + { + $crawler = $this->createTestCrawler(); + $this->assertNotSame($crawler, $crawler->filter('li'), '->filter() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->filter('li'), '->filter() returns a new instance of a crawler'); + + $crawler = $this->createTestCrawler()->filter('ul'); + + $this->assertCount(6, $crawler->filter('li'), '->filter() filters the node list with the CSS selector'); + } + + public function testFilterWithDefaultNamespace() + { + $crawler = $this->createTestXmlCrawler()->filter('default|entry default|id'); + $this->assertCount(1, $crawler, '->filter() automatically registers namespaces'); + $this->assertSame('tag:youtube.com,2008:video:kgZRZmEc9j4', $crawler->text()); + } + + public function testFilterWithNamespace() + { + $crawler = $this->createTestXmlCrawler()->filter('yt|accessControl'); + $this->assertCount(2, $crawler, '->filter() automatically registers namespaces'); + } + + public function testFilterWithMultipleNamespaces() + { + $crawler = $this->createTestXmlCrawler()->filter('media|group yt|aspectRatio'); + $this->assertCount(1, $crawler, '->filter() automatically registers namespaces'); + $this->assertSame('widescreen', $crawler->text()); + } + + public function testFilterWithDefaultNamespaceOnly() + { + $crawler = $this->createCrawler(' + + + http://localhost/foo + weekly + 0.5 + 2012-11-16 + + + http://localhost/bar + weekly + 0.5 + 2012-11-16 + + + '); + + $this->assertEquals(2, $crawler->filter('url')->count()); + } + + public function testSelectLink() + { + $crawler = $this->createTestCrawler(); + $this->assertNotSame($crawler, $crawler->selectLink('Foo'), '->selectLink() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectLink('Foo'), '->selectLink() returns a new instance of a crawler'); + + $this->assertCount(1, $crawler->selectLink('Fabien\'s Foo'), '->selectLink() selects links by the node values'); + $this->assertCount(1, $crawler->selectLink('Fabien\'s Bar'), '->selectLink() selects links by the alt attribute of a clickable image'); + + $this->assertCount(2, $crawler->selectLink('Fabien"s Foo'), '->selectLink() selects links by the node values'); + $this->assertCount(2, $crawler->selectLink('Fabien"s Bar'), '->selectLink() selects links by the alt attribute of a clickable image'); + + $this->assertCount(1, $crawler->selectLink('\' Fabien"s Foo'), '->selectLink() selects links by the node values'); + $this->assertCount(1, $crawler->selectLink('\' Fabien"s Bar'), '->selectLink() selects links by the alt attribute of a clickable image'); + + $this->assertCount(4, $crawler->selectLink('Foo'), '->selectLink() selects links by the node values'); + $this->assertCount(4, $crawler->selectLink('Bar'), '->selectLink() selects links by the node values'); + } + + public function testSelectImage() + { + $crawler = $this->createTestCrawler(); + $this->assertNotSame($crawler, $crawler->selectImage('Bar'), '->selectImage() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectImage('Bar'), '->selectImage() returns a new instance of a crawler'); + + $this->assertCount(1, $crawler->selectImage('Fabien\'s Bar'), '->selectImage() selects images by alt attribute'); + $this->assertCount(2, $crawler->selectImage('Fabien"s Bar'), '->selectImage() selects images by alt attribute'); + $this->assertCount(1, $crawler->selectImage('\' Fabien"s Bar'), '->selectImage() selects images by alt attribute'); + } + + public function testSelectButton() + { + $crawler = $this->createTestCrawler(); + $this->assertNotSame($crawler, $crawler->selectButton('FooValue'), '->selectButton() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->selectButton('FooValue'), '->selectButton() returns a new instance of a crawler'); + + $this->assertEquals(1, $crawler->selectButton('FooValue')->count(), '->selectButton() selects buttons'); + $this->assertEquals(1, $crawler->selectButton('FooName')->count(), '->selectButton() selects buttons'); + $this->assertEquals(1, $crawler->selectButton('FooId')->count(), '->selectButton() selects buttons'); + + $this->assertEquals(1, $crawler->selectButton('BarValue')->count(), '->selectButton() selects buttons'); + $this->assertEquals(1, $crawler->selectButton('BarName')->count(), '->selectButton() selects buttons'); + $this->assertEquals(1, $crawler->selectButton('BarId')->count(), '->selectButton() selects buttons'); + + $this->assertEquals(1, $crawler->selectButton('FooBarValue')->count(), '->selectButton() selects buttons with form attribute too'); + $this->assertEquals(1, $crawler->selectButton('FooBarName')->count(), '->selectButton() selects buttons with form attribute too'); + } + + public function testSelectButtonWithSingleQuotesInNameAttribute() + { + $html = <<<'HTML' + + +
+ Login +
+
+ +
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + + $this->assertCount(1, $crawler->selectButton('Click \'Here\'')); + } + + public function testSelectButtonWithDoubleQuotesInNameAttribute() + { + $html = <<<'HTML' + + +
+ Login +
+
+ +
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + + $this->assertCount(1, $crawler->selectButton('Click "Here"')); + } + + public function testLink() + { + $crawler = $this->createTestCrawler('http://example.com/bar/')->selectLink('Foo'); + $this->assertInstanceOf(Link::class, $crawler->link(), '->link() returns a Link instance'); + + $this->assertEquals('POST', $crawler->link('post')->getMethod(), '->link() takes a method as its argument'); + + $crawler = $this->createTestCrawler('http://example.com/bar')->selectLink('GetLink'); + $this->assertEquals('http://example.com/bar?get=param', $crawler->link()->getUri(), '->link() returns a Link instance'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->link(); + $this->fail('->link() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->link() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testInvalidLink() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The selected node should be instance of "DOM\Element", got "Dom\Text".'); + $crawler = $this->createTestCrawler('http://example.com/bar/'); + $crawler->filterXPath('//li/text()')->link(); + } + + public function testInvalidLinks() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The selected node should be instance of "DOM\Element", got "Dom\Text".'); + $crawler = $this->createTestCrawler('http://example.com/bar/'); + $crawler->filterXPath('//li/text()')->link(); + } + + public function testImage() + { + $crawler = $this->createTestCrawler('http://example.com/bar/')->selectImage('Bar'); + $this->assertInstanceOf(Image::class, $crawler->image(), '->image() returns an Image instance'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->image(); + $this->fail('->image() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->image() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testSelectLinkAndLinkFiltered() + { + $html = <<<'HTML' + + +
+ Login +
+
+ +
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + $filtered = $crawler->filterXPath("descendant-or-self::*[@id = 'login-form']"); + + $this->assertCount(0, $filtered->selectLink('Login')); + $this->assertCount(1, $filtered->selectButton('Submit')); + + $filtered = $crawler->filterXPath("descendant-or-self::*[@id = 'action']"); + + $this->assertCount(1, $filtered->selectLink('Login')); + $this->assertCount(0, $filtered->selectButton('Submit')); + + $this->assertCount(1, $crawler->selectLink('Login')->selectLink('Login')); + $this->assertCount(1, $crawler->selectButton('Submit')->selectButton('Submit')); + } + + public function testChaining() + { + $crawler = $this->createCrawler($this->getDoctype().'
'); + + $this->assertEquals('a', $crawler->filterXPath('//div')->filterXPath('div')->filterXPath('div')->attr('name')); + } + + public function testLinks() + { + $crawler = $this->createTestCrawler('http://example.com/bar/')->selectLink('Foo'); + $this->assertIsArray($crawler->links(), '->links() returns an array'); + + $this->assertCount(4, $crawler->links(), '->links() returns an array'); + $links = $crawler->links(); + $this->assertContainsOnlyInstancesOf('Symfony\\Component\\DomCrawler\\Link', $links, '->links() returns an array of Link instances'); + + $this->assertEquals([], $this->createTestCrawler()->filterXPath('//ol')->links(), '->links() returns an empty array if the node selection is empty'); + } + + public function testImages() + { + $crawler = $this->createTestCrawler('http://example.com/bar/')->selectImage('Bar'); + $this->assertIsArray($crawler->images(), '->images() returns an array'); + + $this->assertCount(4, $crawler->images(), '->images() returns an array'); + $images = $crawler->images(); + $this->assertContainsOnlyInstancesOf('Symfony\\Component\\DomCrawler\\Image', $images, '->images() returns an array of Image instances'); + + $this->assertEquals([], $this->createTestCrawler()->filterXPath('//ol')->links(), '->links() returns an empty array if the node selection is empty'); + } + + public function testForm() + { + $testCrawler = $this->createTestCrawler('http://example.com/bar/'); + $crawler = $testCrawler->selectButton('FooValue'); + $crawler2 = $testCrawler->selectButton('FooBarValue'); + $this->assertInstanceOf(Form::class, $crawler->form(), '->form() returns a Form instance'); + $this->assertInstanceOf(Form::class, $crawler2->form(), '->form() returns a Form instance'); + + $this->assertEquals($crawler->form()->getFormDomNode()->getAttribute('id'), $crawler2->form()->getFormDomNode()->getAttribute('id'), '->form() works on elements with form attribute'); + + $this->assertEquals(['FooName' => 'FooBar', 'TextName' => 'TextValue', 'FooTextName' => 'FooTextValue'], $crawler->form(['FooName' => 'FooBar'])->getValues(), '->form() takes an array of values to submit as its first argument'); + $this->assertEquals(['FooName' => 'FooValue', 'TextName' => 'TextValue', 'FooTextName' => 'FooTextValue'], $crawler->form()->getValues(), '->getValues() returns correct form values'); + $this->assertEquals(['FooBarName' => 'FooBarValue', 'TextName' => 'TextValue', 'FooTextName' => 'FooTextValue'], $crawler2->form()->getValues(), '->getValues() returns correct form values'); + + try { + $this->createTestCrawler()->filterXPath('//ol')->form(); + $this->fail('->form() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->form() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testInvalidForm() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The selected node should be instance of "DOM\Element", got "Dom\Text".'); + $crawler = $this->createTestCrawler('http://example.com/bar/'); + $crawler->filterXPath('//li/text()')->form(); + } + + public function testLast() + { + $crawler = $this->createTestCrawler()->filterXPath('//ul[1]/li'); + $this->assertNotSame($crawler, $crawler->last(), '->last() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->last(), '->last() returns a new instance of a crawler'); + + $this->assertEquals('Three', $crawler->last()->text()); + } + + public function testFirst() + { + $crawler = $this->createTestCrawler()->filterXPath('//li'); + $this->assertNotSame($crawler, $crawler->first(), '->first() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->first(), '->first() returns a new instance of a crawler'); + + $this->assertEquals('One', $crawler->first()->text()); + } + + public function testSiblings() + { + $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(1); + $this->assertNotSame($crawler, $crawler->siblings(), '->siblings() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->siblings(), '->siblings() returns a new instance of a crawler'); + + $nodes = $crawler->siblings(); + $this->assertEquals(2, $nodes->count()); + $this->assertEquals('One', $nodes->eq(0)->text()); + $this->assertEquals('Three', $nodes->eq(1)->text()); + + $nodes = $this->createTestCrawler()->filterXPath('//li')->eq(0)->siblings(); + $this->assertEquals(2, $nodes->count()); + $this->assertEquals('Two', $nodes->eq(0)->text()); + $this->assertEquals('Three', $nodes->eq(1)->text()); + + try { + $this->createTestCrawler()->filterXPath('//ol')->siblings(); + $this->fail('->siblings() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->siblings() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public static function provideMatchTests() + { + yield ['#foo', true, '#foo']; + yield ['#foo', true, '.foo']; + yield ['#foo', true, '.other']; + yield ['#foo', false, '.bar']; + + yield ['#bar', true, '#bar']; + yield ['#bar', true, '.bar']; + yield ['#bar', true, '.other']; + yield ['#bar', false, '.foo']; + } + + /** @dataProvider provideMatchTests */ + public function testMatch(string $mainNodeSelector, bool $expected, string $selector) + { + $html = <<<'HTML' + + +
+
+
+
+
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + $node = $crawler->filter($mainNodeSelector); + $this->assertSame($expected, $node->matches($selector)); + } + + public function testClosest() + { + $html = <<<'HTML' + + +
+
+
+
+
+
+
+
+
+
+
+
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + $foo = $crawler->filter('#foo'); + + $newFoo = $foo->closest('#foo'); + $this->assertInstanceOf(static::getCrawlerClass(), $newFoo); + $this->assertSame('newFoo ok', $newFoo->attr('class')); + + $lorem1 = $foo->closest('.lorem1'); + $this->assertInstanceOf(static::getCrawlerClass(), $lorem1); + $this->assertSame('lorem1 ok', $lorem1->attr('class')); + + $lorem2 = $foo->closest('.lorem2'); + $this->assertInstanceOf(static::getCrawlerClass(), $lorem2); + $this->assertSame('lorem2 ok', $lorem2->attr('class')); + + $lorem3 = $foo->closest('.lorem3'); + $this->assertNull($lorem3); + + $notFound = $foo->closest('.not-found'); + $this->assertNull($notFound); + } + + public function testOuterHtml() + { + $html = <<<'HTML' + + +
+
    +
  • 1
  • +
  • 2
  • +
  • 3
  • +
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + $bar = $crawler->filter('ul'); + $output = $bar->outerHtml(); + $output = str_replace([' ', "\n"], '', $output); + $expected = '
  • 1
  • 2
  • 3
'; + $this->assertSame($expected, $output); + } + + public function testNextAll() + { + $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(1); + $this->assertNotSame($crawler, $crawler->nextAll(), '->nextAll() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->nextAll(), '->nextAll() returns a new instance of a crawler'); + + $nodes = $crawler->nextAll(); + $this->assertEquals(1, $nodes->count()); + $this->assertEquals('Three', $nodes->eq(0)->text()); + + try { + $this->createTestCrawler()->filterXPath('//ol')->nextAll(); + $this->fail('->nextAll() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->nextAll() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testPreviousAll() + { + $crawler = $this->createTestCrawler()->filterXPath('//li')->eq(2); + $this->assertNotSame($crawler, $crawler->previousAll(), '->previousAll() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->previousAll(), '->previousAll() returns a new instance of a crawler'); + + $nodes = $crawler->previousAll(); + $this->assertEquals(2, $nodes->count()); + $this->assertEquals('Two', $nodes->eq(0)->text()); + + try { + $this->createTestCrawler()->filterXPath('//ol')->previousAll(); + $this->fail('->previousAll() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->previousAll() throws an \InvalidArgumentException if the node list is empty'); + } + } + + public function testChildren() + { + $crawler = $this->createTestCrawler()->filterXPath('//ul'); + $this->assertNotSame($crawler, $crawler->children(), '->children() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $crawler->children(), '->children() returns a new instance of a crawler'); + + $nodes = $crawler->children(); + $this->assertEquals(3, $nodes->count()); + $this->assertEquals('One', $nodes->eq(0)->text()); + $this->assertEquals('Two', $nodes->eq(1)->text()); + $this->assertEquals('Three', $nodes->eq(2)->text()); + + try { + $this->createTestCrawler()->filterXPath('//ol')->children(); + $this->fail('->children() throws an \InvalidArgumentException if the node list is empty'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->children() throws an \InvalidArgumentException if the node list is empty'); + } + + try { + $crawler = $this->createCrawler($this->getDoctype().'

'); + $crawler->filter('p')->children(); + $this->assertTrue(true, '->children() does not trigger a notice if the node has no children'); + } catch (\PHPUnit\Framework\Error\Notice $e) { + $this->fail('->children() does not trigger a notice if the node has no children'); + } + } + + public function testFilteredChildren() + { + $html = <<<'HTML' + + +
+
+

+
+
+ +
+ +
+ + +HTML; + + $crawler = $this->createCrawler($this->getDoctype().$html); + $foo = $crawler->filter('#foo'); + + $this->assertEquals(3, $foo->children()->count()); + $this->assertEquals(2, $foo->children('.lorem')->count()); + $this->assertEquals(2, $foo->children('div')->count()); + $this->assertEquals(2, $foo->children('div.lorem')->count()); + $this->assertEquals(1, $foo->children('span')->count()); + $this->assertEquals(1, $foo->children('span.ipsum')->count()); + $this->assertEquals(1, $foo->children('.ipsum')->count()); + } + + public function testAncestors() + { + $crawler = $this->createTestCrawler()->filterXPath('//li[1]'); + + $nodes = $crawler->ancestors(); + + $this->assertNotSame($crawler, $nodes, '->ancestors() returns a new instance of a crawler'); + $this->assertInstanceOf(static::getCrawlerClass(), $nodes, '->ancestors() returns a new instance of a crawler'); + + $this->assertEquals(3, $crawler->ancestors()->count()); + + $this->assertEquals(0, $this->createTestCrawler()->filterXPath('//html')->ancestors()->count()); + } + + public function testAncestorsThrowsIfNodeListIsEmpty() + { + $this->expectException(\InvalidArgumentException::class); + + $this->createTestCrawler()->filterXPath('//ol')->ancestors(); + } + + /** + * @dataProvider getBaseTagData + */ + public function testBaseTag($baseValue, $linkValue, $expectedUri, $currentUri = null, $description = '') + { + $crawler = $this->createCrawler($this->getDoctype() . '', $currentUri); + $this->assertEquals($expectedUri, $crawler->filterXPath('//a')->link()->getUri(), $description); + } + + public static function getBaseTagData() + { + return [ + ['http://base.com', 'link', 'http://base.com/link'], + ['//base.com', 'link', 'https://base.com/link', 'https://domain.com', ' tag can use a schema-less URL'], + ['path/', 'link', 'https://domain.com/path/link', 'https://domain.com', ' tag can set a path'], + ['http://base.com', '#', 'http://base.com#', 'http://domain.com/path/link', ' tag does work with links to an anchor'], + ['http://base.com', '', 'http://base.com', 'http://domain.com/path/link', ' tag does work with empty links'], + ]; + } + + /** + * @dataProvider getBaseTagWithFormData + */ + public function testBaseTagWithForm($baseValue, $actionValue, $expectedUri, $currentUri = null, $description = null) + { + $crawler = $this->createCrawler($this->getDoctype() . '
', $currentUri); + $this->assertEquals($expectedUri, $crawler->filterXPath('//button')->form()->getUri(), $description); + } + + public static function getBaseTagWithFormData() + { + return [ + ['https://base.com/', 'link/', 'https://base.com/link/', 'https://base.com/link/', ' tag does work with a path and relative form action'], + ['/basepath', '/registration', 'http://domain.com/registration', 'http://domain.com/registration', ' tag does work with a path and form action'], + ['/basepath', '', 'http://domain.com/registration', 'http://domain.com/registration', ' tag does work with a path and empty form action'], + ['http://base.com/', '/registration', 'http://base.com/registration', 'http://domain.com/registration', ' tag does work with a URL and form action'], + ['http://base.com/', 'http://base.com/registration', 'http://base.com/registration', null, ' tag does work with a URL and form action'], + ['http://base.com', '', 'http://domain.com/path/form', 'http://domain.com/path/form', ' tag does work with a URL and an empty form action'], + ['http://base.com/path', '/registration', 'http://base.com/registration', 'http://domain.com/path/form', ' tag does work with a URL and form action'], + ]; + } + + public function testCountOfNestedElements() + { + $crawler = $this->createCrawler($this->getDoctype().'
  • List item 1
    • Sublist item 1
    • Sublist item 2
'); + + $this->assertCount(1, $crawler->filter('li:contains("List item 1")')); + } + + public function testEvaluateReturnsTypedResultOfXPathExpressionOnADocumentSubset() + { + $crawler = $this->createTestCrawler(); + + $result = $crawler->filterXPath('//form/input')->evaluate('substring-before(@name, "Name")'); + + $this->assertSame(['Text', 'Foo', 'Bar'], $result); + } + + public function testEvaluateReturnsTypedResultOfNamespacedXPathExpressionOnADocumentSubset() + { + $crawler = $this->createTestXmlCrawler(); + + $result = $crawler->filterXPath('//yt:accessControl/@action')->evaluate('string(.)'); + + $this->assertSame(['comment', 'videoRespond'], $result); + } + + public function testEvaluateReturnsTypedResultOfNamespacedXPathExpression() + { + $crawler = $this->createTestXmlCrawler(); + $crawler->registerNamespace('youtube', 'http://gdata.youtube.com/schemas/2007'); + + $result = $crawler->evaluate('string(//youtube:accessControl/@action)'); + + $this->assertSame(['comment'], $result); + } + + public function testEvaluateReturnsACrawlerIfXPathExpressionEvaluatesToANode() + { + $crawler = $this->createTestCrawler()->evaluate('//form/input[1]'); + + $this->assertInstanceOf(static::getCrawlerClass(), $crawler); + $this->assertCount(1, $crawler); + $this->assertSame('input', $crawler->first()->nodeName()); + } + + public function testEvaluateThrowsAnExceptionIfDocumentIsEmpty() + { + $this->expectException(\LogicException::class); + $this->createCrawler()->evaluate('//form/input[1]'); + } + + public function testAddHtmlContentUnsupportedCharset() + { + $crawler = $this->createCrawler(); + $crawler->addHtmlContent($this->getDoctype().file_get_contents(__DIR__.'/Fixtures/windows-1250.html'), 'Windows-1250'); + + $this->assertEquals('Žťčýů', $crawler->filterXPath('//p')->text()); + } + + public function createTestCrawler($uri = null) + { + $dom = \DOM\HTMLDocument::createFromString($this->getDoctype().' + + + Foo + Fabien\'s Foo + Fabien"s Foo + \' Fabien"s Foo + + Bar +    Fabien\'s Bar   + Fabien"s Bar + \' Fabien"s Bar + + GetLink + + Klausi|Claudiu + +
+ + + + +
+ + + + +
    +
  • One
  • +
  • Two
  • +
  • Three
  • +
+
    +
  • One Bis
  • +
  • Two Bis
  • +
  • Three Bis
  • +
+

+ Elsa + <3 +

+
+
+
+
+
+
+
Parent text Child text
+
Child text Parent text
+
Parent text Child text Parent text
+
Child text
+
Child text Another child
+ +
+ + + ', \DOM\HTML_NO_DEFAULT_NS); + + return $this->createCrawler($dom, $uri); + } + + protected function createTestXmlCrawler($uri = null) + { + $xml = ' + + tag:youtube.com,2008:video:kgZRZmEc9j4 + + + + Chordates - CrashCourse Biology #24 + widescreen + + Music + '; + + return $this->createCrawler($xml, $uri); + } + + protected function createDomDocument() + { + return \DOM\HTMLDocument::createFromString($this->getDoctype().'
', \DOM\HTML_NO_DEFAULT_NS); + } + + protected function createNodeList() + { + $dom = \DOM\HTMLDocument::createFromString($this->getDoctype().'
', \DOM\HTML_NO_DEFAULT_NS); + $domxpath = new \DOM\XPath($dom); + + return $domxpath->query('//div'); + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/Field/FormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/Field/FormFieldTest.php index e2daa03987169..2790e789ab3b5 100644 --- a/src/Symfony/Component/DomCrawler/Tests/Field/FormFieldTest.php +++ b/src/Symfony/Component/DomCrawler/Tests/Field/FormFieldTest.php @@ -11,10 +11,31 @@ namespace Symfony\Component\DomCrawler\Tests\Field; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\DomCrawler\Field\InputFormField; class FormFieldTest extends FormFieldTestCase { + use ExpectDeprecationTrait; + + /** + * @group legacy + */ + public function testGetLabelIsDeprecated() + { + $this->expectDeprecation('Since symfony/dom-crawler 7.1: The "Symfony\Component\DomCrawler\Field\DomFormField::getLabel()" method is deprecated, use "Symfony\Component\DomCrawler\Field\DomFormField::getDomLabel()" instead.'); + + $dom = new \DOMDocument(); + $dom->loadHTML('
+ + + +
'); + + $field = new InputFormField($dom->getElementById('foo')); + $this->assertEquals('Foo label', $field->getLabel()->nodeValue, '->getLabel() returns the associated label'); + } + public function testGetName() { $node = $this->createNode('input', '', ['type' => 'text', 'name' => 'name', 'value' => 'value']); @@ -42,7 +63,7 @@ public function testLabelReturnsNullIfNoneIsDefined() $dom->loadHTML('
'); $field = new InputFormField($dom->getElementById('foo')); - $this->assertNull($field->getLabel(), '->getLabel() returns null if no label is defined'); + $this->assertNull($field->getDomLabel(), '->getDomLabel() returns null if no label is defined'); } public function testLabelIsAssignedByForAttribute() @@ -55,7 +76,7 @@ public function testLabelIsAssignedByForAttribute() '); $field = new InputFormField($dom->getElementById('foo')); - $this->assertEquals('Foo label', $field->getLabel()->textContent, '->getLabel() returns the associated label'); + $this->assertEquals('Foo label', $field->getDomLabel()->textContent, '->getLabel() returns the associated label'); } public function testLabelIsAssignedByParentingRelation() @@ -67,6 +88,6 @@ public function testLabelIsAssignedByParentingRelation() '); $field = new InputFormField($dom->getElementById('foo')); - $this->assertEquals('Foo label', $field->getLabel()->textContent, '->getLabel() returns the parent label'); + $this->assertEquals('Foo label', $field->getDomLabel()->textContent, '->getLabel() returns the parent label'); } } diff --git a/src/Symfony/Component/DomCrawler/Tests/FormTest.php b/src/Symfony/Component/DomCrawler/Tests/FormTest.php index a1698314d5b18..f37cfcd5659d4 100644 --- a/src/Symfony/Component/DomCrawler/Tests/FormTest.php +++ b/src/Symfony/Component/DomCrawler/Tests/FormTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DomCrawler\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\DomCrawler\Field\ChoiceFormField; use Symfony\Component\DomCrawler\Field\FormField; use Symfony\Component\DomCrawler\Field\InputFormField; @@ -21,22 +22,56 @@ class FormTest extends TestCase { + use ExpectDeprecationTrait; + public static function setUpBeforeClass(): void { // Ensure that the private helper class FormFieldRegistry is loaded class_exists(Form::class); } - public function testConstructorThrowsExceptionIfTheNodeHasNoFormAncestor() + private function createDocument(bool $legacy, string $content): \DOMDocument|\DOM\Document { - $dom = new \DOMDocument(); - $dom->loadHTML(' + if ($legacy) { + $dom = new \DOMDocument(); + $dom->loadHTML($content); + + return $dom; + } + + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('This test requires PHP 8.4 or higher.'); + } + + return \DOM\HTMLDocument::createFromString(''.$content, \DOM\HTML_NO_DEFAULT_NS); + } + + /** + * @group legacy + */ + public function testGetFormNodeIsDeprecated() + { + $this->expectDeprecation('Since symfony/dom-crawler 7.1: The "Symfony\Component\DomCrawler\Form::getFormNode()" method is deprecated, use "Symfony\Component\DomCrawler\Form::getFormDomNode()" instead.'); + + $dom = $this->createDocument(true, '
'); + + $form = new Form($dom->getElementsByTagName('input')->item(0), 'http://example.com'); + $form->getFormNode(); + } + + /** + * @testWith [true] + * [false] + */ + public function testConstructorThrowsExceptionIfTheNodeHasNoFormAncestor(bool $useLegacyNodes) + { + $dom = $this->createDocument($useLegacyNodes, ' - +
- +
- ', null, 'http://localhost/foo/', useLegacyNode: $useLegacyNode); $this->assertEquals('http://localhost/bar', $form->getUri(), '->getUri() returns absolute URIs'); } @@ -596,100 +796,112 @@ public static function provideGetUriValues() return [ [ 'returns the URI of the form', - '
', + '
', [], '/foo', ], [ 'appends the form values if the method is get', - '
', + '
', [], '/foo?foo=foo', ], [ 'appends the form values and merges the submitted values', - '
', + '
', ['foo' => 'bar'], '/foo?foo=bar', ], [ 'does not append values if the method is post', - '
', + '
', [], '/foo', ], [ 'does not append values if the method is patch', - '
', + '
', [], '/foo', 'PUT', ], [ 'does not append values if the method is delete', - '
', + '
', [], '/foo', 'DELETE', ], [ 'does not append values if the method is put', - '
', + '
', [], '/foo', 'PATCH', ], [ 'appends the form values to an existing query string', - '
', + '
', [], '/foo?bar=bar&foo=foo', ], [ 'replaces query values with the form values', - '
', + '
', [], '/foo?bar=foo', ], [ 'returns an empty URI if the action is empty', - '
', + '
', [], '/', ], [ 'appends the form values even if the action is empty', - '
', + '
', [], '/?foo=foo', ], [ 'chooses the path if the action attribute value is a sharp (#)', - '
', + '
', [], '/#', ], ]; } - public function testHas() + /** + * @testWith [true] + * [false] + */ + public function testHas(bool $useLegacyNode) { - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $this->assertFalse($form->has('foo'), '->has() returns false if a field is not in the form'); $this->assertTrue($form->has('bar'), '->has() returns true if a field is in the form'); } - public function testRemove() + /** + * @testWith [true] + * [false] + */ + public function testRemove(bool $useLegacyNode) { - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $form->remove('bar'); $this->assertFalse($form->has('bar'), '->remove() removes a field'); } - public function testGet() + /** + * @testWith [true] + * [false] + */ + public function testGet(bool $useLegacyNode) { - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $this->assertInstanceOf(InputFormField::class, $form->get('bar'), '->get() returns the field object associated with the given name'); @@ -701,39 +913,54 @@ public function testGet() } } - public function testAll() + /** + * @testWith [true] + * [false] + */ + public function testAll(bool $useLegacyNode) { - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $fields = $form->all(); $this->assertCount(1, $fields, '->all() return an array of form field objects'); $this->assertInstanceOf(InputFormField::class, $fields['bar'], '->all() return an array of form field objects'); } - public function testSubmitWithoutAFormButton() + /** + * @testWith [true] + * [false] + */ + public function testSubmitWithoutAFormButton(bool $useLegacyNode) { - $dom = new \DOMDocument(); - $dom->loadHTML(' + $dom = $this->createDocument($useLegacyNode, '
- +
'); $nodes = $dom->getElementsByTagName('form'); $form = new Form($nodes->item(0), 'http://example.com'); - $this->assertSame($nodes->item(0), $form->getFormNode(), '->getFormNode() returns the form node associated with this form'); + $this->assertSame($nodes->item(0), $form->getFormDomNode(), '->getFormDomNode() returns the form node associated with this form'); } - public function testTypeAttributeIsCaseInsensitive() + /** + * @testWith [true] + * [false] + */ + public function testTypeAttributeIsCaseInsensitive(bool $useLegacyNode) { - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $this->assertTrue($form->has('example.x'), '->has() returns true if the image input was correctly turned into an x and a y fields'); $this->assertTrue($form->has('example.y'), '->has() returns true if the image input was correctly turned into an x and a y fields'); } - public function testFormFieldRegistryAcceptAnyNames() + /** + * @testWith [true] + * [false] + */ + public function testFormFieldRegistryAcceptAnyNames(bool $useLegacyNode) { $field = $this->getFormFieldMock('[t:dbt%3adate;]data_daterange_enddate_value'); @@ -742,7 +969,7 @@ public function testFormFieldRegistryAcceptAnyNames() $this->assertEquals($field, $registry->get('[t:dbt%3adate;]data_daterange_enddate_value')); $registry->set('[t:dbt%3adate;]data_daterange_enddate_value', null); - $form = $this->createForm('
'); + $form = $this->createForm('
', useLegacyNode: $useLegacyNode); $form['[t:dbt%3adate;]data_daterange_enddate_value'] = 'bar'; $registry->remove('[t:dbt%3adate;]data_daterange_enddate_value'); @@ -854,10 +1081,13 @@ public function testFormRegistrySetArrayOnNotCompoundField() $registry->set('bar', ['baz']); } - public function testDifferentFieldTypesWithSameName() + /** + * @testWith [true] + * [false] + */ + public function testDifferentFieldTypesWithSameName(bool $useLegacyNode) { - $dom = new \DOMDocument(); - $dom->loadHTML(' + $dom = $this->createDocument($useLegacyNode, '
@@ -900,38 +1130,46 @@ protected function getFormFieldMock($name, $value = null) return $field; } - protected function createForm($form, $method = null, $currentUri = null) + protected function createForm($form, $method = null, $currentUri = null, bool $useLegacyNode = true) { - $dom = new \DOMDocument(); - @$dom->loadHTML(''.$form.''); + if ($useLegacyNode) { + $dom = new \DOMDocument(); + @$dom->loadHTML(''.$form.''); + + $xPath = new \DOMXPath($dom); + } else { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('This test requires PHP 8.4.0 or higher.'); + } + + $dom = \DOM\HTMLDocument::createFromString(''.$form.'', \DOM\HTML_NO_DEFAULT_NS); + $xPath = new \DOM\XPath($dom); + } - $xPath = new \DOMXPath($dom); $nodes = $xPath->query('//input | //button'); - $currentUri ??= 'http://example.com/'; return new Form($nodes->item($nodes->length - 1), $currentUri, $method); } - protected function createTestHtml5Form() + protected function createTestHtml5Form(bool $useLegacyNode = true) { - $dom = new \DOMDocument(); - $dom->loadHTML(' + $html = '

Hello form

-
- -
- +
+ +
+
- +
-
-
- - +
+
+ +
@@ -941,61 +1179,87 @@ protected function createTestHtml5Form()
- +
- -
-
-
- - - -
- + +
+
+
+ + + +
+ ', null, 'http://localhost/foo/', useLegacyNode: $useLegacyNode); + $form = $this->createForm('
- +
-
-
- - +
+
+ +
@@ -1179,87 +941,61 @@ protected function createTestHtml5Form(bool $useLegacyNode = true)
- +
- -
-
-
- - - -
- + +
+
+
+ + + +
+ ', $currentUri); + $crawler = $this->createCrawler($this->getDoctype().'
', $currentUri); $this->assertEquals($expectedUri, $crawler->filterXPath('//button')->form()->getUri(), $description); } @@ -1299,7 +1294,7 @@ public function testEvaluateThrowsAnExceptionIfDocumentIsEmpty() public function testAddHtmlContentUnsupportedCharset() { $crawler = $this->createCrawler(); - $crawler->addHtmlContent($this->getDoctype().file_get_contents(__DIR__.'/Fixtures/windows-1250.html'), 'Windows-1250'); + $crawler->addHtmlContent($this->getDoctype().file_get_contents(__DIR__.'/../Fixtures/windows-1250.html'), 'Windows-1250'); $this->assertEquals('Žťčýů', $crawler->filterXPath('//p')->text()); } diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/ChoiceFormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/ChoiceFormFieldTest.php new file mode 100644 index 0000000000000..d4bf0fc2735f1 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/ChoiceFormFieldTest.php @@ -0,0 +1,408 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\NativeCrawler\Field\ChoiceFormField; + +/** + * @requires PHP 8.4 + */ +class ChoiceFormFieldTest extends FormFieldTestCase +{ + public function testInitialize() + { + $node = $this->createNode('textarea'); + try { + new ChoiceFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is not an input or a select'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is not an input or a select'); + } + + $node = $this->createNode('input', ['type' => 'text']); + try { + new ChoiceFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is an input with a type different from checkbox or radio'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is an input with a type different from checkbox or radio'); + } + } + + public function testGetType() + { + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $this->assertEquals('radio', $field->getType(), '->getType() returns radio for radio buttons'); + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $this->assertEquals('checkbox', $field->getType(), '->getType() returns radio for a checkbox'); + + $node = $this->createNode('select'); + $field = new ChoiceFormField($node); + + $this->assertEquals('select', $field->getType(), '->getType() returns radio for a select'); + } + + public function testIsMultiple() + { + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $this->assertFalse($field->isMultiple(), '->isMultiple() returns false for radio buttons'); + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $this->assertFalse($field->isMultiple(), '->isMultiple() returns false for checkboxes'); + + $node = $this->createNode('select'); + $field = new ChoiceFormField($node); + + $this->assertFalse($field->isMultiple(), '->isMultiple() returns false for selects without the multiple attribute'); + + $node = $this->createNode('select', ['multiple' => 'multiple']); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->isMultiple(), '->isMultiple() returns true for selects with the multiple attribute'); + + $node = $this->createNode('select', ['multiple' => '']); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->isMultiple(), '->isMultiple() returns true for selects with an empty multiple attribute'); + } + + public function testSelects() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->hasValue(), '->hasValue() returns true for selects'); + $this->assertEquals('foo', $field->getValue(), '->getValue() returns the first option if none are selected'); + $this->assertFalse($field->isMultiple(), '->isMultiple() returns false when no multiple attribute is defined'); + + $node = $this->createSelectNode(['foo' => false, 'bar' => true]); + $field = new ChoiceFormField($node); + + $this->assertEquals('bar', $field->getValue(), '->getValue() returns the selected option'); + + $field->setValue('foo'); + $this->assertEquals('foo', $field->getValue(), '->setValue() changes the selected option'); + + try { + $field->setValue('foobar'); + $this->fail('->setValue() throws an \InvalidArgumentException if the value is not one of the selected options'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setValue() throws an \InvalidArgumentException if the value is not one of the selected options'); + } + + try { + $field->setValue(['foobar']); + $this->fail('->setValue() throws an \InvalidArgumentException if the value is an array'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setValue() throws an \InvalidArgumentException if the value is an array'); + } + } + + public function testSelectWithEmptyBooleanAttribute() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => true], [], ''); + $field = new ChoiceFormField($node); + + $this->assertEquals('bar', $field->getValue()); + } + + public function testSelectIsDisabled() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => true], ['disabled' => 'disabled']); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->isDisabled(), '->isDisabled() returns true for selects with a disabled attribute'); + } + + public function testMultipleSelects() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => false], ['multiple' => 'multiple']); + $field = new ChoiceFormField($node); + + $this->assertEquals([], $field->getValue(), '->setValue() returns an empty array if multiple is true and no option is selected'); + + $field->setValue('foo'); + $this->assertEquals(['foo'], $field->getValue(), '->setValue() returns an array of options if multiple is true'); + + $field->setValue('bar'); + $this->assertEquals(['bar'], $field->getValue(), '->setValue() returns an array of options if multiple is true'); + + $field->setValue(['foo', 'bar']); + $this->assertEquals(['foo', 'bar'], $field->getValue(), '->setValue() returns an array of options if multiple is true'); + + $node = $this->createSelectNode(['foo' => true, 'bar' => true], ['multiple' => 'multiple']); + $field = new ChoiceFormField($node); + + $this->assertEquals(['foo', 'bar'], $field->getValue(), '->getValue() returns the selected options'); + + try { + $field->setValue(['foobar']); + $this->fail('->setValue() throws an \InvalidArgumentException if the value is not one of the options'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setValue() throws an \InvalidArgumentException if the value is not one of the options'); + } + } + + public function testRadioButtons() + { + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'bar']); + $field->addChoice($node); + + $this->assertFalse($field->hasValue(), '->hasValue() returns false when no radio button is selected'); + $this->assertNull($field->getValue(), '->getValue() returns null if no radio button is selected'); + $this->assertFalse($field->isMultiple(), '->isMultiple() returns false for radio buttons'); + + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'bar', 'checked' => 'checked']); + $field->addChoice($node); + + $this->assertTrue($field->hasValue(), '->hasValue() returns true when a radio button is selected'); + $this->assertEquals('bar', $field->getValue(), '->getValue() returns the value attribute of the selected radio button'); + + $field->setValue('foo'); + $this->assertEquals('foo', $field->getValue(), '->setValue() changes the selected radio button'); + + try { + $field->setValue('foobar'); + $this->fail('->setValue() throws an \InvalidArgumentException if the value is not one of the radio button values'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setValue() throws an \InvalidArgumentException if the value is not one of the radio button values'); + } + } + + public function testRadioButtonsWithEmptyBooleanAttribute() + { + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo']); + $field = new ChoiceFormField($node); + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'bar', 'checked' => '']); + $field->addChoice($node); + + $this->assertTrue($field->hasValue(), '->hasValue() returns true when a radio button is selected'); + $this->assertEquals('bar', $field->getValue(), '->getValue() returns the value attribute of the selected radio button'); + } + + public function testRadioButtonIsDisabled() + { + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'foo', 'disabled' => 'disabled']); + $field = new ChoiceFormField($node); + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'bar']); + $field->addChoice($node); + $node = $this->createNode('input', ['type' => 'radio', 'name' => 'name', 'value' => 'baz', 'disabled' => '']); + $field->addChoice($node); + + $field->select('foo'); + $this->assertEquals('foo', $field->getValue(), '->getValue() returns the value attribute of the selected radio button'); + $this->assertTrue($field->isDisabled()); + + $field->select('bar'); + $this->assertEquals('bar', $field->getValue(), '->getValue() returns the value attribute of the selected radio button'); + $this->assertFalse($field->isDisabled()); + + $field->select('baz'); + $this->assertEquals('baz', $field->getValue(), '->getValue() returns the value attribute of the selected radio button'); + $this->assertTrue($field->isDisabled()); + } + + public function testCheckboxes() + { + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name']); + $field = new ChoiceFormField($node); + + $this->assertFalse($field->hasValue(), '->hasValue() returns false when the checkbox is not checked'); + $this->assertNull($field->getValue(), '->getValue() returns null if the checkbox is not checked'); + $this->assertFalse($field->isMultiple(), '->hasValue() returns false for checkboxes'); + try { + $field->addChoice(\DOM\HTMLDocument::createEmpty()->createElement('input')); + $this->fail('->addChoice() throws a \LogicException for checkboxes'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException for checkboxes'); + } + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'checked' => 'checked']); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->hasValue(), '->hasValue() returns true when the checkbox is checked'); + $this->assertEquals('on', $field->getValue(), '->getValue() returns 1 if the checkbox is checked and has no value attribute'); + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'checked' => 'checked', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $this->assertEquals('foo', $field->getValue(), '->getValue() returns the value attribute if the checkbox is checked'); + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'checked' => 'checked', 'value' => 'foo']); + $field = new ChoiceFormField($node); + + $field->setValue(false); + $this->assertNull($field->getValue(), '->setValue() unchecks the checkbox is value is false'); + + $field->setValue(true); + $this->assertEquals('foo', $field->getValue(), '->setValue() checks the checkbox is value is true'); + + try { + $field->setValue('bar'); + $this->fail('->setValue() throws an \InvalidArgumentException if the value is not one from the value attribute'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setValue() throws an \InvalidArgumentException if the value is not one from the value attribute'); + } + } + + public function testCheckboxWithEmptyBooleanAttribute() + { + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'value' => 'foo', 'checked' => '']); + $field = new ChoiceFormField($node); + + $this->assertTrue($field->hasValue(), '->hasValue() returns true when the checkbox is checked'); + $this->assertEquals('foo', $field->getValue()); + } + + public function testTick() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + + try { + $field->tick(); + $this->fail('->tick() throws a \LogicException for select boxes'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->tick() throws a \LogicException for select boxes'); + } + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name']); + $field = new ChoiceFormField($node); + $field->tick(); + $this->assertEquals('on', $field->getValue(), '->tick() ticks checkboxes'); + } + + public function testUntick() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + + try { + $field->untick(); + $this->fail('->untick() throws a \LogicException for select boxes'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->untick() throws a \LogicException for select boxes'); + } + + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'checked' => 'checked']); + $field = new ChoiceFormField($node); + $field->untick(); + $this->assertNull($field->getValue(), '->untick() unticks checkboxes'); + } + + public function testSelect() + { + $node = $this->createNode('input', ['type' => 'checkbox', 'name' => 'name', 'checked' => 'checked']); + $field = new ChoiceFormField($node); + $field->select(true); + $this->assertEquals('on', $field->getValue(), '->select() changes the value of the field'); + $field->select(false); + $this->assertNull($field->getValue(), '->select() changes the value of the field'); + + $node = $this->createSelectNode(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + $field->select('foo'); + $this->assertEquals('foo', $field->getValue(), '->select() changes the selected option'); + } + + public function testOptionWithNoValue() + { + $node = $this->createSelectNodeWithEmptyOption(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + $this->assertEquals('foo', $field->getValue()); + + $node = $this->createSelectNodeWithEmptyOption(['foo' => false, 'bar' => true]); + $field = new ChoiceFormField($node); + $this->assertEquals('bar', $field->getValue()); + $field->select('foo'); + $this->assertEquals('foo', $field->getValue(), '->select() changes the selected option'); + } + + public function testDisableValidation() + { + $node = $this->createSelectNode(['foo' => false, 'bar' => false]); + $field = new ChoiceFormField($node); + $field->disableValidation(); + $field->setValue('foobar'); + $this->assertEquals('foobar', $field->getValue(), '->disableValidation() allows to set a value which is not in the selected options.'); + + $node = $this->createSelectNode(['foo' => false, 'bar' => false], ['multiple' => 'multiple']); + $field = new ChoiceFormField($node); + $field->disableValidation(); + $field->setValue(['foobar']); + $this->assertEquals(['foobar'], $field->getValue(), '->disableValidation() allows to set a value which is not in the selected options.'); + } + + public function testSelectWithEmptyValue() + { + $node = $this->createSelectNodeWithEmptyOption(['' => true, 'Female' => false, 'Male' => false]); + $field = new ChoiceFormField($node); + + $this->assertSame('', $field->getValue()); + } + + protected function createSelectNode($options, $attributes = [], $selectedAttrText = 'selected') + { + $document = \DOM\HTMLDocument::createEmpty(); + $node = $document->createElement('select'); + + foreach ($attributes as $name => $value) { + $node->setAttribute($name, $value); + } + $node->setAttribute('name', 'name'); + + foreach ($options as $value => $selected) { + $option = $document->createElement('option'); + $option->setAttribute('value', $value); + if ($selected) { + $option->setAttribute('selected', $selectedAttrText); + } + $node->appendChild($option); + } + + return $node; + } + + protected function createSelectNodeWithEmptyOption($options, $attributes = []) + { + $document = \DOM\HTMLDocument::createEmpty(); + $node = $document->createElement('select'); + + foreach ($attributes as $name => $value) { + $node->setAttribute($name, $value); + } + $node->setAttribute('name', 'name'); + + foreach ($options as $value => $selected) { + $option = $document->createElement('option'); + $option->setAttribute('value', $value); + if ($selected) { + $option->setAttribute('selected', 'selected'); + } + $node->appendChild($option); + } + + return $node; + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FileFormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FileFormFieldTest.php new file mode 100644 index 0000000000000..8f6a0f0302dc7 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FileFormFieldTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\NativeCrawler\Field\FileFormField; + +/** + * @requires PHP 8.4 + */ +class FileFormFieldTest extends FormFieldTestCase +{ + public function testInitialize() + { + $node = $this->createNode('input', ['type' => 'file']); + $field = new FileFormField($node); + + $this->assertEquals(['name' => '', 'type' => '', 'tmp_name' => '', 'error' => \UPLOAD_ERR_NO_FILE, 'size' => 0], $field->getValue(), '->initialize() sets the value of the field to no file uploaded'); + + $node = $this->createNode('textarea'); + try { + new FileFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is not an input field'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is not an input field'); + } + + $node = $this->createNode('input', ['type' => 'text']); + try { + new FileFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is not a file input field'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is not a file input field'); + } + } + + /** + * @dataProvider getSetValueMethods + */ + public function testSetValue($method) + { + $node = $this->createNode('input', ['type' => 'file']); + $field = new FileFormField($node); + + $field->$method(null); + $this->assertEquals(['name' => '', 'type' => '', 'tmp_name' => '', 'error' => \UPLOAD_ERR_NO_FILE, 'size' => 0], $field->getValue(), "->$method() clears the uploaded file if the value is null"); + + $field->$method(__FILE__); + $value = $field->getValue(); + + $this->assertEquals(basename(__FILE__), $value['name'], "->$method() sets the name of the file field"); + $this->assertEquals('', $value['type'], "->$method() sets the type of the file field"); + $this->assertIsString($value['tmp_name'], "->$method() sets the tmp_name of the file field"); + $this->assertFileExists($value['tmp_name'], "->$method() creates a copy of the file at the tmp_name path"); + $this->assertEquals(0, $value['error'], "->$method() sets the error of the file field"); + $this->assertEquals(filesize(__FILE__), $value['size'], "->$method() sets the size of the file field"); + + $origInfo = pathinfo(__FILE__); + $tmpInfo = pathinfo($value['tmp_name']); + $this->assertEquals( + $origInfo['extension'], + $tmpInfo['extension'], + "->$method() keeps the same file extension in the tmp_name copy" + ); + + $field->$method(__DIR__.'/../Fixtures/no-extension'); + $value = $field->getValue(); + + $this->assertArrayNotHasKey( + 'extension', + pathinfo($value['tmp_name']), + "->$method() does not add a file extension in the tmp_name copy" + ); + } + + public static function getSetValueMethods() + { + return [ + ['setValue'], + ['upload'], + ]; + } + + public function testSetErrorCode() + { + $node = $this->createNode('input', ['type' => 'file']); + $field = new FileFormField($node); + + $field->setErrorCode(\UPLOAD_ERR_FORM_SIZE); + $value = $field->getValue(); + $this->assertEquals(\UPLOAD_ERR_FORM_SIZE, $value['error'], '->setErrorCode() sets the file input field error code'); + + try { + $field->setErrorCode(12345); + $this->fail('->setErrorCode() throws a \InvalidArgumentException if the error code is not valid'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->setErrorCode() throws a \InvalidArgumentException if the error code is not valid'); + } + } + + public function testSetRawFilePath() + { + $node = $this->createNode('input', ['type' => 'file']); + $field = new FileFormField($node); + $field->setFilePath(__FILE__); + + $this->assertEquals(__FILE__, $field->getValue()); + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTest.php new file mode 100644 index 0000000000000..5f34eda19a7d2 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\NativeCrawler\Field\InputFormField; + +/** + * @requires PHP 8.4 + */ +class FormFieldTest extends FormFieldTestCase +{ + public function testGetName() + { + $node = $this->createNode('input', ['type' => 'text', 'name' => 'name', 'value' => 'value']); + $field = new InputFormField($node); + + $this->assertEquals('name', $field->getName(), '->getName() returns the name of the field'); + } + + public function testGetSetHasValue() + { + $node = $this->createNode('input', ['type' => 'text', 'name' => 'name', 'value' => 'value']); + $field = new InputFormField($node); + + $this->assertEquals('value', $field->getValue(), '->getValue() returns the value of the field'); + + $field->setValue('foo'); + $this->assertEquals('foo', $field->getValue(), '->setValue() sets the value of the field'); + + $this->assertTrue($field->hasValue(), '->hasValue() always returns true'); + } + + public function testLabelReturnsNullIfNoneIsDefined() + { + $dom = \DOM\HTMLDocument::createFromString('
'); + + $field = new InputFormField($dom->getElementById('foo')); + $this->assertNull($field->getLabel(), '->getLabel() returns null if no label is defined'); + } + + public function testLabelIsAssignedByForAttribute() + { + $dom = \DOM\HTMLDocument::createFromString('
+ + + +
', \DOM\HTML_NO_DEFAULT_NS); + + $field = new InputFormField($dom->getElementById('foo')); + $this->assertEquals('Foo label', $field->getLabel()->textContent, '->getLabel() returns the associated label'); + } + + public function testLabelIsAssignedByParentingRelation() + { + $dom = \DOM\HTMLDocument::createFromString('
+ + +
', \DOM\HTML_NO_DEFAULT_NS); + + $field = new InputFormField($dom->getElementById('foo')); + $this->assertEquals('Foo label', $field->getLabel()->textContent, '->getLabel() returns the parent label'); + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTestCase.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTestCase.php new file mode 100644 index 0000000000000..fd07699264d1e --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/FormFieldTestCase.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use PHPUnit\Framework\TestCase; + +/** + * @requires PHP 8.4 + */ +class FormFieldTestCase extends TestCase +{ + protected function createNode($tag, $attributes = [], ?string $value = null) + { + $node = \DOM\HTMLDocument::createEmpty()->createElement($tag); + + if (null !== $value) { + $node->textContent = $value; + } + + foreach ($attributes as $name => $value) { + $node->setAttribute($name, $value); + } + + return $node; + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/InputFormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/InputFormFieldTest.php new file mode 100644 index 0000000000000..16d53bc64a385 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/InputFormFieldTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\NativeCrawler\Field\InputFormField; + +/** + * @requires PHP 8.4 + */ +class InputFormFieldTest extends FormFieldTestCase +{ + public function testInitialize() + { + $node = $this->createNode('input', ['type' => 'text', 'name' => 'name', 'value' => 'value']); + $field = new InputFormField($node); + + $this->assertEquals('value', $field->getValue(), '->initialize() sets the value of the field to the value attribute value'); + + $node = $this->createNode('textarea'); + try { + new InputFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is not an input'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is not an input'); + } + + $node = $this->createNode('input', ['type' => 'checkbox']); + try { + new InputFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is a checkbox'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is a checkbox'); + } + + $node = $this->createNode('input', ['type' => 'file']); + try { + new InputFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is a file'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is a file'); + } + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/TextareaFormFieldTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/TextareaFormFieldTest.php new file mode 100644 index 0000000000000..c14aa4ac9df9e --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/Field/TextareaFormFieldTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\NativeCrawler\Field\TextareaFormField; + +/** + * @requires PHP 8.4 + */ +class TextareaFormFieldTest extends FormFieldTestCase +{ + public function testInitialize() + { + $node = $this->createNode('textarea', value: 'foo bar'); + $field = new TextareaFormField($node); + + $this->assertEquals('foo bar', $field->getValue(), '->initialize() sets the value of the field to the textarea node value'); + + $node = $this->createNode('input'); + try { + new TextareaFormField($node); + $this->fail('->initialize() throws a \LogicException if the node is not a textarea'); + } catch (\LogicException $e) { + $this->assertTrue(true, '->initialize() throws a \LogicException if the node is not a textarea'); + } + + // Ensure that valid HTML can be used on a textarea. + $node = $this->createNode('textarea', value: 'foo bar

Baz

'); + $field = new TextareaFormField($node); + + $this->assertEquals('foo bar

Baz

', $field->getValue(), '->initialize() sets the value of the field to the textarea node value'); + + // Ensure that we don't do any DOM manipulation/validation by passing in + // "invalid" HTML. + $node = $this->createNode('textarea', value: 'foo bar

Baz

'); + $field = new TextareaFormField($node); + + $this->assertEquals('foo bar

Baz

', $field->getValue(), '->initialize() sets the value of the field to the textarea node value'); + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/FormTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/FormTest.php new file mode 100644 index 0000000000000..20f5f42b938d9 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/FormTest.php @@ -0,0 +1,1034 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Tests\NativeCrawler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DomCrawler\NativeCrawler\Field\ChoiceFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\FormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\InputFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\TextareaFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Form; +use Symfony\Component\DomCrawler\NativeCrawler\FormFieldRegistry; + +/** + * @requires PHP 8.4 + */ +class FormTest extends TestCase +{ + public static function setUpBeforeClass(): void + { + // Ensure that the private helper class FormFieldRegistry is loaded + class_exists(Form::class); + } + + private function createDocument(string $content): \DOM\Document + { + return \DOM\HTMLDocument::createFromString(''.$content, \DOM\HTML_NO_DEFAULT_NS); + } + + public function testConstructorThrowsExceptionIfTheNodeHasNoFormAncestor() + { + $dom = $this->createDocument(' + + +
+ +
+ ', + ['bar' => ['InputFormField', 'bar']], + ], + [ + 'appends the submitted button value but not other submit buttons', + ' + ', + ['foobar' => ['InputFormField', 'foobar']], + ], + [ + 'turns an image input into x and y fields', + '', + ['bar.x' => ['InputFormField', '0'], 'bar.y' => ['InputFormField', '0']], + ], + [ + 'returns textareas', + ' + ', + ['foo' => ['TextareaFormField', 'foo']], + ], + [ + 'returns inputs', + ' + ', + ['foo' => ['InputFormField', 'foo']], + ], + [ + 'returns checkboxes', + ' + ', + ['foo' => ['ChoiceFormField', 'foo']], + ], + [ + 'returns not-checked checkboxes', + ' + ', + ['foo' => ['ChoiceFormField', false]], + ], + [ + 'returns radio buttons', + ' + + ', + ['foo' => ['ChoiceFormField', 'bar']], + ], + [ + 'returns file inputs', + ' + ', + ['foo' => ['FileFormField', ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]]], + ], + ]; + } + + public function testGetFormDomNode() + { + $dom = $this->createDocument('
'); + + $form = new Form($dom->getElementsByTagName('input')->item(0), 'http://example.com'); + + $this->assertSame($dom->getElementsByTagName('form')->item(0), $form->getFormNode(), '->getFormNode() returns the form node associated with this form'); + } + + public function testGetFormNodeFromNamedForm() + { + $dom = $this->createDocument('
'); + + $form = new Form($dom->getElementsByTagName('form')->item(0), 'http://example.com'); + + $this->assertSame($dom->getElementsByTagName('form')->item(0), $form->getFormNode(), '->getFormNode() returns the form node associated with this form'); + } + + public function testGetMethod() + { + $form = $this->createForm('
'); + $this->assertEquals('GET', $form->getMethod(), '->getMethod() returns get if no method is defined'); + + $form = $this->createForm('
'); + $this->assertEquals('POST', $form->getMethod(), '->getMethod() returns the method attribute value of the form'); + + $form = $this->createForm('
', 'put'); + $this->assertEquals('PUT', $form->getMethod(), '->getMethod() returns the method defined in the constructor if provided'); + + $form = $this->createForm('
', 'delete'); + $this->assertEquals('DELETE', $form->getMethod(), '->getMethod() returns the method defined in the constructor if provided'); + + $form = $this->createForm('
', 'patch'); + $this->assertEquals('PATCH', $form->getMethod(), '->getMethod() returns the method defined in the constructor if provided'); + } + + public function testGetMethodWithOverride() + { + $form = $this->createForm('
'); + $this->assertEquals('POST', $form->getMethod(), '->getMethod() returns the method attribute value of the form'); + } + + public function testGetName() + { + $form = $this->createForm('
'); + $this->assertSame('foo', $form->getName()); + } + + public function testGetNameOnFormWithoutName() + { + $form = $this->createForm('
'); + $this->assertSame('', $form->getName()); + } + + public function testGetSetValue() + { + $form = $this->createForm('
'); + + $this->assertEquals('foo', $form['foo']->getValue(), '->offsetGet() returns the value of a form field'); + + $form['foo'] = 'bar'; + + $this->assertEquals('bar', $form['foo']->getValue(), '->offsetSet() changes the value of a form field'); + + try { + $form['foobar'] = 'bar'; + $this->fail('->offsetSet() throws an \InvalidArgumentException exception if the field does not exist'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->offsetSet() throws an \InvalidArgumentException exception if the field does not exist'); + } + + try { + $form['foobar']; + $this->fail('->offsetSet() throws an \InvalidArgumentException exception if the field does not exist'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->offsetSet() throws an \InvalidArgumentException exception if the field does not exist'); + } + } + + public function testDisableValidation() + { + $form = $this->createForm('
+ + + +
'); + + $form->disableValidation(); + + $form['foo[bar]']->select('foo'); + $form['foo[baz]']->select('bar'); + $this->assertEquals('foo', $form['foo[bar]']->getValue(), '->disableValidation() disables validation of all ChoiceFormField.'); + $this->assertEquals('bar', $form['foo[baz]']->getValue(), '->disableValidation() disables validation of all ChoiceFormField.'); + } + + public function testOffsetUnset() + { + $form = $this->createForm('
'); + unset($form['foo']); + $this->assertArrayNotHasKey('foo', $form, '->offsetUnset() removes a field'); + } + + public function testOffsetExists() + { + $form = $this->createForm('
'); + + $this->assertArrayHasKey('foo', $form, '->offsetExists() return true if the field exists'); + $this->assertArrayNotHasKey('bar', $form, '->offsetExists() return false if the field does not exist'); + } + + public function testGetValues() + { + $form = $this->createForm('
'); + $this->assertEquals(['foo[bar]' => 'foo', 'bar' => 'bar', 'baz' => []], $form->getValues(), '->getValues() returns all form field values'); + + $form = $this->createForm('
'); + $this->assertEquals(['bar' => 'bar'], $form->getValues(), '->getValues() does not include not-checked checkboxes'); + + $form = $this->createForm('
'); + $this->assertEquals(['bar' => 'bar'], $form->getValues(), '->getValues() does not include file input fields'); + + $form = $this->createForm('
'); + $this->assertEquals(['bar' => 'bar'], $form->getValues(), '->getValues() does not include disabled fields'); + + $form = $this->createForm('
'); + $this->assertEquals(['bar' => 'bar'], $form->getValues(), '->getValues() does not include template fields'); + $this->assertFalse($form->has('foo')); + + $form = $this->createForm(''); + $this->assertEquals(['foo[bar]' => 'foo', 'bar' => 'bar', 'baz' => []], $form->getValues(), '->getValues() returns all form field values from template field inside a turbo-stream'); + } + + public function testSetValues() + { + $form = $this->createForm('
'); + $form->setValues(['foo' => false, 'bar' => 'foo']); + $this->assertEquals(['bar' => 'foo'], $form->getValues(), '->setValues() sets the values of fields'); + } + + public function testMultiselectSetValues() + { + $form = $this->createForm('
'); + $form->setValues(['multi' => ['foo', 'bar']]); + $this->assertEquals(['multi' => ['foo', 'bar']], $form->getValues(), '->setValue() sets the values of select'); + } + + public function testGetPhpValues() + { + $form = $this->createForm('
'); + $this->assertEquals(['foo' => ['bar' => 'foo'], 'bar' => 'bar'], $form->getPhpValues(), '->getPhpValues() converts keys with [] to arrays'); + + $form = $this->createForm('
'); + $this->assertEquals(['fo.o' => ['ba.r' => 'foo'], 'ba r' => 'bar'], $form->getPhpValues(), '->getPhpValues() preserves periods and spaces in names'); + + $form = $this->createForm('
'); + $this->assertEquals(['fo.o' => ['ba.r' => ['foo', 'ba.z' => 'bar']]], $form->getPhpValues(), '->getPhpValues() preserves periods and spaces in names recursively'); + + $form = $this->createForm('
'); + $this->assertEquals(['foo' => ['bar' => 'foo'], 'bar' => 'bar'], $form->getPhpValues(), "->getPhpValues() doesn't return empty values"); + } + + public function testGetFiles() + { + $form = $this->createForm('
'); + $this->assertEquals([], $form->getFiles(), '->getFiles() returns an empty array if method is get'); + + $form = $this->createForm('
'); + $this->assertEquals(['foo[bar]' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]], $form->getFiles(), '->getFiles() only returns file fields for POST'); + + $form = $this->createForm('
', 'put'); + $this->assertEquals(['foo[bar]' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]], $form->getFiles(), '->getFiles() only returns file fields for PUT'); + + $form = $this->createForm('
', 'delete'); + $this->assertEquals(['foo[bar]' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]], $form->getFiles(), '->getFiles() only returns file fields for DELETE'); + + $form = $this->createForm('
', 'patch'); + $this->assertEquals(['foo[bar]' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]], $form->getFiles(), '->getFiles() only returns file fields for PATCH'); + + $form = $this->createForm('
'); + $this->assertEquals([], $form->getFiles(), '->getFiles() does not include disabled file fields'); + + $form = $this->createForm('
'); + $this->assertEquals([], $form->getFiles(), '->getFiles() does not include template file fields'); + $this->assertFalse($form->has('foo')); + + $form = $this->createForm(''); + $this->assertEquals(['foo[bar]' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]], $form->getFiles(), '->getFiles() return files fields from template inside turbo-stream'); + } + + public function testGetPhpFiles() + { + $form = $this->createForm('
'); + $this->assertEquals(['foo' => ['bar' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]]], $form->getPhpFiles(), '->getPhpFiles() converts keys with [] to arrays'); + + $form = $this->createForm('
'); + $this->assertEquals(['f.o o' => ['bar' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]]], $form->getPhpFiles(), '->getPhpFiles() preserves periods and spaces in names'); + + $form = $this->createForm('
'); + $this->assertEquals(['f.o o' => ['bar' => ['ba.z' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0], ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]]]], $form->getPhpFiles(), '->getPhpFiles() preserves periods and spaces in names recursively'); + + $form = $this->createForm('
'); + $files = $form->getPhpFiles(); + + $this->assertSame(0, $files['foo']['bar']['size'], '->getPhpFiles() converts size to int'); + $this->assertSame(4, $files['foo']['bar']['error'], '->getPhpFiles() converts error to int'); + + $form = $this->createForm('
'); + $this->assertEquals(['size' => ['error' => ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0]]], $form->getPhpFiles(), '->getPhpFiles() int conversion does not collide with file names'); + } + + /** + * @dataProvider provideGetUriValues + */ + public function testGetUri($message, $form, $values, $uri, $method = null) + { + $form = $this->createForm($form, $method); + $form->setValues($values); + + $this->assertEquals('http://example.com'.$uri, $form->getUri(), '->getUri() '.$message); + } + + public function testGetBaseUri() + { + $dom = $this->createDocument('
'); + + $nodes = $dom->getElementsByTagName('input'); + $form = new Form($nodes->item($nodes->length - 1), 'http://www.foo.com/'); + $this->assertEquals('http://www.foo.com/foo.php', $form->getUri()); + } + + public function testGetUriWithAnchor() + { + $form = $this->createForm('
', null, 'http://example.com/id/123'); + + $this->assertEquals('http://example.com/id/123#foo', $form->getUri()); + } + + public function testGetUriActionAbsolute() + { + $formHtml = '
'; + + $form = $this->createForm($formHtml); + $this->assertEquals('https://login.foo.com/login.php?login_attempt=1', $form->getUri(), '->getUri() returns absolute URIs set in the action form'); + + $form = $this->createForm($formHtml, null, 'https://login.foo.com'); + $this->assertEquals('https://login.foo.com/login.php?login_attempt=1', $form->getUri(), '->getUri() returns absolute URIs set in the action form'); + + $form = $this->createForm($formHtml, null, 'https://login.foo.com/bar/'); + $this->assertEquals('https://login.foo.com/login.php?login_attempt=1', $form->getUri(), '->getUri() returns absolute URIs set in the action form'); + + // The action URI haven't the same domain Host have an another domain as Host + $form = $this->createForm($formHtml, null, 'https://www.foo.com'); + $this->assertEquals('https://login.foo.com/login.php?login_attempt=1', $form->getUri(), '->getUri() returns absolute URIs set in the action form'); + + $form = $this->createForm($formHtml, null, 'https://www.foo.com/bar/'); + $this->assertEquals('https://login.foo.com/login.php?login_attempt=1', $form->getUri(), '->getUri() returns absolute URIs set in the action form'); + } + + public function testGetUriAbsolute() + { + $form = $this->createForm('
', null, 'http://localhost/foo/'); + $this->assertEquals('http://localhost/foo/foo', $form->getUri(), '->getUri() returns absolute URIs'); + + $form = $this->createForm('
', null, 'http://localhost/foo/'); + $this->assertEquals('http://localhost/foo', $form->getUri(), '->getUri() returns absolute URIs'); + } + + public function testGetUriWithOnlyQueryString() + { + $form = $this->createForm('
', null, 'http://localhost/foo/bar'); + $this->assertEquals('http://localhost/foo/bar?get=param', $form->getUri(), '->getUri() returns absolute URIs only if the host has been defined in the constructor'); + } + + public function testGetUriWithoutAction() + { + $form = $this->createForm('
', null, 'http://localhost/foo/bar'); + $this->assertEquals('http://localhost/foo/bar', $form->getUri(), '->getUri() returns path if no action defined'); + } + + public function testGetUriWithActionOverride() + { + $form = $this->createForm('
', null, 'http://localhost/foo/'); + $this->assertEquals('http://localhost/bar', $form->getUri(), '->getUri() returns absolute URIs'); + } + + public static function provideGetUriValues() + { + return [ + [ + 'returns the URI of the form', + '
', + [], + '/foo', + ], + [ + 'appends the form values if the method is get', + '
', + [], + '/foo?foo=foo', + ], + [ + 'appends the form values and merges the submitted values', + '
', + ['foo' => 'bar'], + '/foo?foo=bar', + ], + [ + 'does not append values if the method is post', + '
', + [], + '/foo', + ], + [ + 'does not append values if the method is patch', + '
', + [], + '/foo', + 'PUT', + ], + [ + 'does not append values if the method is delete', + '
', + [], + '/foo', + 'DELETE', + ], + [ + 'does not append values if the method is put', + '
', + [], + '/foo', + 'PATCH', + ], + [ + 'appends the form values to an existing query string', + '
', + [], + '/foo?bar=bar&foo=foo', + ], + [ + 'replaces query values with the form values', + '
', + [], + '/foo?bar=foo', + ], + [ + 'returns an empty URI if the action is empty', + '
', + [], + '/', + ], + [ + 'appends the form values even if the action is empty', + '
', + [], + '/?foo=foo', + ], + [ + 'chooses the path if the action attribute value is a sharp (#)', + '
', + [], + '/#', + ], + ]; + } + + public function testHas() + { + $form = $this->createForm('
'); + + $this->assertFalse($form->has('foo'), '->has() returns false if a field is not in the form'); + $this->assertTrue($form->has('bar'), '->has() returns true if a field is in the form'); + } + + public function testRemove() + { + $form = $this->createForm('
'); + $form->remove('bar'); + $this->assertFalse($form->has('bar'), '->remove() removes a field'); + } + + public function testGet() + { + $form = $this->createForm('
'); + + $this->assertInstanceOf(InputFormField::class, $form->get('bar'), '->get() returns the field object associated with the given name'); + + try { + $form->get('foo'); + $this->fail('->get() throws an \InvalidArgumentException if the field does not exist'); + } catch (\InvalidArgumentException $e) { + $this->assertTrue(true, '->get() throws an \InvalidArgumentException if the field does not exist'); + } + } + + public function testAll() + { + $form = $this->createForm('
'); + + $fields = $form->all(); + $this->assertCount(1, $fields, '->all() return an array of form field objects'); + $this->assertInstanceOf(InputFormField::class, $fields['bar'], '->all() return an array of form field objects'); + } + + public function testSubmitWithoutAFormButton() + { + $dom = $this->createDocument(' + +
+ +
+ + '); + + $nodes = $dom->getElementsByTagName('form'); + $form = new Form($nodes->item(0), 'http://example.com'); + $this->assertSame($nodes->item(0), $form->getFormNode(), '->getFormNode() returns the form node associated with this form'); + } + + public function testTypeAttributeIsCaseInsensitive() + { + $form = $this->createForm('
'); + $this->assertTrue($form->has('example.x'), '->has() returns true if the image input was correctly turned into an x and a y fields'); + $this->assertTrue($form->has('example.y'), '->has() returns true if the image input was correctly turned into an x and a y fields'); + } + + public function testFormFieldRegistryAcceptAnyNames() + { + $field = $this->getFormFieldMock('[t:dbt%3adate;]data_daterange_enddate_value'); + + $registry = new FormFieldRegistry(); + $registry->add($field); + $this->assertEquals($field, $registry->get('[t:dbt%3adate;]data_daterange_enddate_value')); + $registry->set('[t:dbt%3adate;]data_daterange_enddate_value', null); + + $form = $this->createForm('
'); + $form['[t:dbt%3adate;]data_daterange_enddate_value'] = 'bar'; + + $registry->remove('[t:dbt%3adate;]data_daterange_enddate_value'); + } + + public function testFormFieldRegistryGetThrowAnExceptionWhenTheFieldDoesNotExist() + { + $this->expectException(\InvalidArgumentException::class); + $registry = new FormFieldRegistry(); + $registry->get('foo'); + } + + public function testFormFieldRegistrySetThrowAnExceptionWhenTheFieldDoesNotExist() + { + $this->expectException(\InvalidArgumentException::class); + $registry = new FormFieldRegistry(); + $registry->set('foo', null); + } + + public function testFormFieldRegistryHasReturnsTrueWhenTheFQNExists() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo[bar]')); + + $this->assertTrue($registry->has('foo')); + $this->assertTrue($registry->has('foo[bar]')); + $this->assertFalse($registry->has('bar')); + $this->assertFalse($registry->has('foo[foo]')); + } + + public function testFormRegistryFieldsCanBeRemoved() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo')); + $registry->remove('foo'); + $this->assertFalse($registry->has('foo')); + } + + public function testFormRegistrySupportsMultivaluedFields() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo[]')); + $registry->add($this->getFormFieldMock('foo[]')); + $registry->add($this->getFormFieldMock('bar[5]')); + $registry->add($this->getFormFieldMock('bar[]')); + $registry->add($this->getFormFieldMock('bar[baz]')); + + $this->assertEquals( + ['foo[0]', 'foo[1]', 'bar[5]', 'bar[6]', 'bar[baz]'], + array_keys($registry->all()) + ); + } + + public function testFormRegistrySetValues() + { + $registry = new FormFieldRegistry(); + $registry->add($f2 = $this->getFormFieldMock('foo[2]')); + $registry->add($f3 = $this->getFormFieldMock('foo[3]')); + $registry->add($fbb = $this->getFormFieldMock('foo[bar][baz]')); + + $f2 + ->expects($this->exactly(2)) + ->method('setValue') + ->with(2) + ; + + $f3 + ->expects($this->exactly(2)) + ->method('setValue') + ->with(3) + ; + + $fbb + ->expects($this->exactly(2)) + ->method('setValue') + ->with('fbb') + ; + + $registry->set('foo[2]', 2); + $registry->set('foo[3]', 3); + $registry->set('foo[bar][baz]', 'fbb'); + + $registry->set('foo', [ + 2 => 2, + 3 => 3, + 'bar' => [ + 'baz' => 'fbb', + ], + ]); + } + + public function testFormRegistrySetValueOnCompoundField() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set value on a compound field "foo[bar]".'); + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo[bar][baz]')); + + $registry->set('foo[bar]', 'fbb'); + } + + public function testFormRegistrySetArrayOnNotCompoundField() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unreachable field "0"'); + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('bar')); + + $registry->set('bar', ['baz']); + } + + public function testDifferentFieldTypesWithSameName() + { + $dom = $this->createDocument(' + + +
+ + + + + + +
+ + + '); + $form = new Form($dom->getElementsByTagName('form')->item(0), 'http://example.com'); + + $this->assertInstanceOf(ChoiceFormField::class, $form->get('option')); + } + + protected function getFormFieldMock($name, $value = null) + { + $field = $this + ->getMockBuilder(FormField::class) + ->onlyMethods(['getName', 'getValue', 'setValue', 'initialize']) + ->disableOriginalConstructor() + ->getMock() + ; + + $field + ->expects($this->any()) + ->method('getName') + ->willReturn($name) + ; + + $field + ->expects($this->any()) + ->method('getValue') + ->willReturn($value) + ; + + return $field; + } + + protected function createForm($form, $method = null, $currentUri = null) + { + $dom = \DOM\HTMLDocument::createFromString(''.$form.'', \DOM\HTML_NO_DEFAULT_NS); + $xPath = new \DOM\XPath($dom); + + $nodes = $xPath->query('//input | //button'); + $currentUri ??= 'http://example.com/'; + + return new Form($nodes->item($nodes->length - 1), $currentUri, $method); + } + + protected function createTestHtml5Form() + { + $html = ' + +

Hello form

+
+
+ +
+ + +
+ +
+
+
+ + + +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ + + +
+