diff --git a/src/Symfony/Component/DomCrawler/CHANGELOG.md b/src/Symfony/Component/DomCrawler/CHANGELOG.md index 53395956f3be9..e3f1793666bbd 100644 --- a/src/Symfony/Component/DomCrawler/CHANGELOG.md +++ b/src/Symfony/Component/DomCrawler/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add `NativeCrawler\*` classes to parse HTML and XML with native PHP parser + 7.0 --- diff --git a/src/Symfony/Component/DomCrawler/Crawler.php b/src/Symfony/Component/DomCrawler/Crawler.php index 23da2edc43867..3b7e560eab618 100644 --- a/src/Symfony/Component/DomCrawler/Crawler.php +++ b/src/Symfony/Component/DomCrawler/Crawler.php @@ -12,7 +12,6 @@ namespace Symfony\Component\DomCrawler; use Masterminds\HTML5; -use Symfony\Component\CssSelector\CssSelectorConverter; /** * Crawler eases navigation of a list of \DOMNode objects. @@ -23,24 +22,8 @@ */ class Crawler implements \Countable, \IteratorAggregate { - /** - * The default namespace prefix to be used with XPath and CSS expressions. - */ - private string $defaultNamespacePrefix = 'default'; - - /** - * A map of manually registered namespaces. - * - * @var array - */ - private array $namespaces = []; + use CrawlerTrait; - /** - * A map of cached namespaces. - */ - private \ArrayObject $cachedNamespaces; - - private ?string $baseHref; private ?\DOMDocument $document = null; /** @@ -48,12 +31,7 @@ class Crawler implements \Countable, \IteratorAggregate */ private array $nodes = []; - /** - * Whether the Crawler contains HTML or XML content (used when converting CSS to XPath). - */ - private bool $isHtml = true; - - private ?HTML5 $html5Parser = null; + private ?HTML5 $html5Parser; /** * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $node A Node to use as the base for the crawling @@ -71,32 +49,6 @@ public function __construct( $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. * @@ -122,43 +74,6 @@ public function add(\DOMNodeList|\DOMNode|array|string|null $node): void } } - /** - * 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. * @@ -177,7 +92,7 @@ public function addHtmlContent(string $content, string $charset = 'UTF-8'): void $base = $this->filterRelativeXPath('descendant-or-self::base')->extract(['href']); $baseHref = current($base); - if (\count($base) && $baseHref) { + if (\count($base) && !empty($baseHref)) { if ($this->baseHref) { $linkNode = $dom->createElement('a'); $linkNode->setAttribute('href', $baseHref); @@ -289,251 +204,6 @@ public function addNode(\DOMNode $node): void $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. * @@ -691,71 +361,6 @@ public function extract(array $attributes): array 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. * @@ -862,172 +467,6 @@ public function form(?array $values = null, ?string $method = null): Form 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. - */ - 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): ?\DOMNode { return $this->nodes[$position] ?? null; @@ -1101,28 +540,6 @@ private function parseXhtml(string $htmlContent, string $charset = 'UTF-8'): \DO return $dom; } - /** - * Converts charset to HTML-entities to ensure valid parsing. - */ - 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 */ @@ -1159,15 +576,6 @@ private function discoverNamespace(\DOMXPath $domxpath, string $prefix): ?string return $this->cachedNamespaces[$prefix] = ($node = $namespaces->item(0)) ? $node->nodeValue : null; } - 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. * @@ -1185,18 +593,6 @@ private function createSubCrawler(\DOMNodeList|\DOMNode|array|string|null $nodes 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. @@ -1224,14 +620,4 @@ private function canParseHtml5String(string $content): bool return '' === $header || $this->isValidHtml5Heading($header); } - - 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/CrawlerTrait.php b/src/Symfony/Component/DomCrawler/CrawlerTrait.php new file mode 100644 index 0000000000000..d65e788bcf1c4 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/CrawlerTrait.php @@ -0,0 +1,638 @@ + + * + * 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; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait CrawlerTrait +{ + /** + * The default namespace prefix to be used with XPath and CSS expressions. + */ + private string $defaultNamespacePrefix = 'default'; + + /** + * A map of manually registered namespaces. + * + * @var array + */ + private array $namespaces = []; + + /** + * A map of cached namespaces. + */ + private \ArrayObject $cachedNamespaces; + + private ?string $baseHref; + + /** + * Whether the Crawler contains HTML or XML content (used when converting CSS to XPath). + */ + private bool $isHtml = true; + + /** + * 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 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); + } + } + + /** + * 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; + } + + /** + * 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)) + ); + } + + /** + * 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. + */ + 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 + } + + /** + * Converts charset to HTML-entities to ensure valid parsing. + */ + 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(); + } + } + + 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 []; + } + + /** + * @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); + } + + 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..7da1edd976610 100644 --- a/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php @@ -20,119 +20,7 @@ */ class ChoiceFormField extends FormField { - private string $type; - private bool $multiple; - private array $options; - private bool $validationDisabled = false; - - /** - * Returns true if the field should be included in the submitted values. - * - * @return bool true if the field should be included in the submitted values, false otherwise - */ - public function hasValue(): bool - { - // don't send a value for unchecked checkboxes - if (\in_array($this->type, ['checkbox', 'radio']) && null === $this->value) { - return false; - } - - return true; - } - - /** - * Check if the current selected option is disabled. - */ - public function isDisabled(): bool - { - if (parent::isDisabled() && 'select' === $this->type) { - return true; - } - - foreach ($this->options as $option) { - if ($option['value'] == $this->value && $option['disabled']) { - return true; - } - } - - return false; - } - - /** - * Sets the value of the field. - */ - public function select(string|array|bool $value): void - { - $this->setValue($value); - } - - /** - * Ticks a checkbox. - * - * @throws \LogicException When the type provided is not correct - */ - public function tick(): void - { - if ('checkbox' !== $this->type) { - throw new \LogicException(\sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); - } - - $this->setValue(true); - } - - /** - * Unticks a checkbox. - * - * @throws \LogicException When the type provided is not correct - */ - public function untick(): void - { - if ('checkbox' !== $this->type) { - throw new \LogicException(\sprintf('You cannot untick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); - } - - $this->setValue(false); - } - - /** - * Sets the value of the field. - * - * @throws \InvalidArgumentException When value type provided is not correct - */ - public function setValue(string|array|bool|null $value): void - { - if ('checkbox' === $this->type && false === $value) { - // uncheck - $this->value = null; - } elseif ('checkbox' === $this->type && true === $value) { - // check - $this->value = $this->options[0]['value']; - } else { - if (\is_array($value)) { - if (!$this->multiple) { - throw new \InvalidArgumentException(\sprintf('The value for "%s" cannot be an array.', $this->name)); - } - - foreach ($value as $v) { - if (!$this->containsOption($v, $this->options)) { - throw new \InvalidArgumentException(\sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $v, implode('", "', $this->availableOptionValues()))); - } - } - } elseif (!$this->containsOption($value, $this->options)) { - throw new \InvalidArgumentException(\sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $value, implode('", "', $this->availableOptionValues()))); - } - - if ($this->multiple) { - $value = (array) $value; - } - - if (\is_array($value)) { - $this->value = $value; - } else { - parent::setValue($value); - } - } - } + use ChoiceFormFieldTrait; /** * Adds a choice to the current ones. @@ -155,22 +43,6 @@ public function addChoice(\DOMElement $node): void } } - /** - * Returns the type of the choice field (radio, select, or checkbox). - */ - public function getType(): string - { - return $this->type; - } - - /** - * Returns true if the field accepts multiple values. - */ - public function isMultiple(): bool - { - return $this->multiple; - } - /** * Initializes the form field. * @@ -242,54 +114,4 @@ private function buildOptionValue(\DOMElement $node): array return $option; } - - /** - * Checks whether given value is in the existing options. - * - * @internal - */ - public function containsOption(string $optionValue, array $options): bool - { - if ($this->validationDisabled) { - return true; - } - - foreach ($options as $option) { - if ($option['value'] == $optionValue) { - return true; - } - } - - return false; - } - - /** - * Returns list of available field options. - * - * @internal - */ - public function availableOptionValues(): array - { - $values = []; - - foreach ($this->options as $option) { - $values[] = $option['value']; - } - - return $values; - } - - /** - * Disables the internal validation of the field. - * - * @internal - * - * @return $this - */ - public function disableValidation(): static - { - $this->validationDisabled = true; - - return $this; - } } diff --git a/src/Symfony/Component/DomCrawler/Field/ChoiceFormFieldTrait.php b/src/Symfony/Component/DomCrawler/Field/ChoiceFormFieldTrait.php new file mode 100644 index 0000000000000..3a3f4b32b1afd --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/ChoiceFormFieldTrait.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait ChoiceFormFieldTrait +{ + private string $type; + private bool $multiple; + private array $options; + private bool $validationDisabled = false; + + /** + * Returns true if the field should be included in the submitted values. + * + * @return bool true if the field should be included in the submitted values, false otherwise + */ + public function hasValue(): bool + { + // don't send a value for unchecked checkboxes + if (\in_array($this->type, ['checkbox', 'radio']) && null === $this->value) { + return false; + } + + return true; + } + + /** + * Check if the current selected option is disabled. + */ + public function isDisabled(): bool + { + if (parent::isDisabled() && 'select' === $this->type) { + return true; + } + + foreach ($this->options as $option) { + if ($option['value'] == $this->value && $option['disabled']) { + return true; + } + } + + return false; + } + + /** + * Sets the value of the field. + */ + public function select(string|array|bool $value): void + { + $this->setValue($value); + } + + /** + * Ticks a checkbox. + * + * @throws \LogicException When the type provided is not correct + */ + public function tick(): void + { + if ('checkbox' !== $this->type) { + throw new \LogicException(\sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); + } + + $this->setValue(true); + } + + /** + * Unticks a checkbox. + * + * @throws \LogicException When the type provided is not correct + */ + public function untick(): void + { + if ('checkbox' !== $this->type) { + throw new \LogicException(\sprintf('You cannot untick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); + } + + $this->setValue(false); + } + + /** + * Sets the value of the field. + * + * @throws \InvalidArgumentException When value type provided is not correct + */ + public function setValue(string|array|bool|null $value): void + { + if ('checkbox' === $this->type && false === $value) { + // uncheck + $this->value = null; + } elseif ('checkbox' === $this->type && true === $value) { + // check + $this->value = $this->options[0]['value']; + } else { + if (\is_array($value)) { + if (!$this->multiple) { + throw new \InvalidArgumentException(\sprintf('The value for "%s" cannot be an array.', $this->name)); + } + + foreach ($value as $v) { + if (!$this->containsOption($v, $this->options)) { + throw new \InvalidArgumentException(\sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $v, implode('", "', $this->availableOptionValues()))); + } + } + } elseif (!$this->containsOption($value, $this->options)) { + throw new \InvalidArgumentException(\sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $value, implode('", "', $this->availableOptionValues()))); + } + + if ($this->multiple) { + $value = (array) $value; + } + + if (\is_array($value)) { + $this->value = $value; + } else { + parent::setValue($value); + } + } + } + + /** + * Returns the type of the choice field (radio, select, or checkbox). + */ + public function getType(): string + { + return $this->type; + } + + /** + * Returns true if the field accepts multiple values. + */ + public function isMultiple(): bool + { + return $this->multiple; + } + + /** + * Checks whether given value is in the existing options. + * + * @internal + */ + public function containsOption(string $optionValue, array $options): bool + { + if ($this->validationDisabled) { + return true; + } + + foreach ($options as $option) { + if ($option['value'] == $optionValue) { + return true; + } + } + + return false; + } + + /** + * Returns list of available field options. + * + * @internal + */ + public function availableOptionValues(): array + { + $values = []; + + foreach ($this->options as $option) { + $values[] = $option['value']; + } + + return $values; + } + + /** + * Disables the internal validation of the field. + * + * @internal + * + * @return $this + */ + public function disableValidation(): static + { + $this->validationDisabled = true; + + return $this; + } +} diff --git a/src/Symfony/Component/DomCrawler/Field/FileFormField.php b/src/Symfony/Component/DomCrawler/Field/FileFormField.php index 5580fd859d878..5b6ae39c7455b 100644 --- a/src/Symfony/Component/DomCrawler/Field/FileFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/FileFormField.php @@ -18,86 +18,5 @@ */ class FileFormField extends FormField { - /** - * Sets the PHP error code associated with the field. - * - * @param int $error The error code (one of UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, or UPLOAD_ERR_EXTENSION) - * - * @throws \InvalidArgumentException When error code doesn't exist - */ - public function setErrorCode(int $error): void - { - $codes = [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, \UPLOAD_ERR_PARTIAL, \UPLOAD_ERR_NO_FILE, \UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION]; - if (!\in_array($error, $codes)) { - throw new \InvalidArgumentException(\sprintf('The error code "%s" is not valid.', $error)); - } - - $this->value = ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => $error, 'size' => 0]; - } - - /** - * Sets the value of the field. - */ - public function upload(?string $value): void - { - $this->setValue($value); - } - - /** - * Sets the value of the field. - */ - public function setValue(?string $value): void - { - if (null !== $value && is_readable($value)) { - $error = \UPLOAD_ERR_OK; - $size = filesize($value); - $info = pathinfo($value); - $name = $info['basename']; - - // copy to a tmp location - $tmp = tempnam(sys_get_temp_dir(), $name); - if (\array_key_exists('extension', $info)) { - unlink($tmp); - $tmp .= '.'.$info['extension']; - } - if (is_file($tmp)) { - unlink($tmp); - } - copy($value, $tmp); - $value = $tmp; - } else { - $error = \UPLOAD_ERR_NO_FILE; - $size = 0; - $name = ''; - $value = ''; - } - - $this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size]; - } - - /** - * Sets path to the file as string for simulating HTTP request. - */ - public function setFilePath(string $path): void - { - parent::setValue($path); - } - - /** - * Initializes the form field. - * - * @throws \LogicException When node type is incorrect - */ - 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)); - } - - 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'))); - } - - $this->setValue(null); - } + use FileFormFieldTrait; } diff --git a/src/Symfony/Component/DomCrawler/Field/FileFormFieldTrait.php b/src/Symfony/Component/DomCrawler/Field/FileFormFieldTrait.php new file mode 100644 index 0000000000000..fb5da65fdd705 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/FileFormFieldTrait.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait FileFormFieldTrait +{ + /** + * Sets the PHP error code associated with the field. + * + * @param int $error The error code (one of UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, or UPLOAD_ERR_EXTENSION) + * + * @throws \InvalidArgumentException When error code doesn't exist + */ + public function setErrorCode(int $error): void + { + $codes = [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, \UPLOAD_ERR_PARTIAL, \UPLOAD_ERR_NO_FILE, \UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION]; + if (!\in_array($error, $codes)) { + throw new \InvalidArgumentException(\sprintf('The error code "%s" is not valid.', $error)); + } + + $this->value = ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => $error, 'size' => 0]; + } + + /** + * Sets the value of the field. + */ + public function upload(?string $value): void + { + $this->setValue($value); + } + + /** + * Sets the value of the field. + */ + public function setValue(?string $value): void + { + if (null !== $value && is_readable($value)) { + $error = \UPLOAD_ERR_OK; + $size = filesize($value); + $info = pathinfo($value); + $name = $info['basename']; + + // copy to a tmp location + $tmp = tempnam(sys_get_temp_dir(), $name); + if (\array_key_exists('extension', $info)) { + unlink($tmp); + $tmp .= '.'.$info['extension']; + } + if (is_file($tmp)) { + unlink($tmp); + } + copy($value, $tmp); + $value = $tmp; + } else { + $error = \UPLOAD_ERR_NO_FILE; + $size = 0; + $name = ''; + $value = ''; + } + + $this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size]; + } + + /** + * Sets path to the file as string for simulating HTTP request. + */ + public function setFilePath(string $path): void + { + parent::setValue($path); + } + + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize(): void + { + $nodeName = strtolower($this->node->nodeName); + if ('input' !== $nodeName) { + throw new \LogicException(\sprintf('A FileFormField can only be created from an input tag (%s given).', $nodeName)); + } + + $attribute = strtolower($this->node->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/FormField.php b/src/Symfony/Component/DomCrawler/Field/FormField.php index fb1a0ae7e22ef..c2cd00e337816 100644 --- a/src/Symfony/Component/DomCrawler/Field/FormField.php +++ b/src/Symfony/Component/DomCrawler/Field/FormField.php @@ -18,11 +18,10 @@ */ abstract class FormField { - protected string $name; - protected string|array|null $value = null; + use FormFieldTrait; + protected \DOMDocument $document; protected \DOMXPath $xpath; - protected bool $disabled = false; /** * @param \DOMElement $node The node associated with this field @@ -55,38 +54,6 @@ public function getLabel(): ?\DOMElement return $labels->length > 0 ? $labels->item(0) : null; } - /** - * Returns the name of the field. - */ - public function getName(): string - { - return $this->name; - } - - /** - * Gets the value of the field. - */ - public function getValue(): string|array|null - { - return $this->value; - } - - /** - * Sets the value of the field. - */ - public function setValue(?string $value): void - { - $this->value = $value ?? ''; - } - - /** - * Returns true if the field should be included in the submitted values. - */ - public function hasValue(): bool - { - return true; - } - /** * Check if the current field is disabled. */ @@ -94,9 +61,4 @@ public function isDisabled(): bool { return $this->node->hasAttribute('disabled'); } - - /** - * Initializes the form field. - */ - abstract protected function initialize(): void; } diff --git a/src/Symfony/Component/DomCrawler/Field/FormFieldTrait.php b/src/Symfony/Component/DomCrawler/Field/FormFieldTrait.php new file mode 100644 index 0000000000000..1ce905bcfc3e1 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/FormFieldTrait.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait FormFieldTrait +{ + protected string $name; + protected string|array|null $value = null; + protected bool $disabled = false; + + /** + * Returns the name of the field. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the value of the field. + */ + public function getValue(): string|array|null + { + return $this->value; + } + + /** + * Sets the value of the field. + */ + public function setValue(?string $value): void + { + $this->value = $value ?? ''; + } + + /** + * Returns true if the field should be included in the submitted values. + */ + public function hasValue(): bool + { + return true; + } + + /** + * Initializes the form field. + */ + abstract protected function initialize(): void; +} diff --git a/src/Symfony/Component/DomCrawler/Field/InputFormField.php b/src/Symfony/Component/DomCrawler/Field/InputFormField.php index 1e26e5cfdd6e0..27edbd1e13633 100644 --- a/src/Symfony/Component/DomCrawler/Field/InputFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/InputFormField.php @@ -21,26 +21,5 @@ */ class InputFormField extends FormField { - /** - * Initializes the form field. - * - * @throws \LogicException When node type is incorrect - */ - 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)); - } - - $type = strtolower($this->node->getAttribute('type')); - if ('checkbox' === $type) { - throw new \LogicException('Checkboxes should be instances of ChoiceFormField.'); - } - - if ('file' === $type) { - throw new \LogicException('File inputs should be instances of FileFormField.'); - } - - $this->value = $this->node->getAttribute('value'); - } + use InputFormFieldTrait; } diff --git a/src/Symfony/Component/DomCrawler/Field/InputFormFieldTrait.php b/src/Symfony/Component/DomCrawler/Field/InputFormFieldTrait.php new file mode 100644 index 0000000000000..96d8ebaaf9bdf --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/InputFormFieldTrait.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait InputFormFieldTrait +{ + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize(): void + { + $nodeName = strtolower($this->node->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')); + if ('checkbox' === $type) { + throw new \LogicException('Checkboxes should be instances of ChoiceFormField.'); + } + + if ('file' === $type) { + throw new \LogicException('File inputs should be instances of FileFormField.'); + } + + $this->value = $this->node->getAttribute('value') ?? ''; + } +} diff --git a/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php b/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php index b246776a9e4cb..0b220789e8907 100644 --- a/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/TextareaFormField.php @@ -18,20 +18,5 @@ */ class TextareaFormField extends FormField { - /** - * Initializes the form field. - * - * @throws \LogicException When node type is incorrect - */ - 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)); - } - - $this->value = ''; - foreach ($this->node->childNodes as $node) { - $this->value .= $node->wholeText; - } - } + use TextareaFormFieldTrait; } diff --git a/src/Symfony/Component/DomCrawler/Field/TextareaFormFieldTrait.php b/src/Symfony/Component/DomCrawler/Field/TextareaFormFieldTrait.php new file mode 100644 index 0000000000000..4ef3dd4891b3e --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Field/TextareaFormFieldTrait.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait TextareaFormFieldTrait +{ + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize(): void + { + $nodeName = strtolower($this->node->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) { + $this->value .= $node->wholeText; + } + } +} diff --git a/src/Symfony/Component/DomCrawler/Form.php b/src/Symfony/Component/DomCrawler/Form.php index 54ed5d9577459..a9b8e86eba3fd 100644 --- a/src/Symfony/Component/DomCrawler/Form.php +++ b/src/Symfony/Component/DomCrawler/Form.php @@ -21,6 +21,8 @@ */ class Form extends Link implements \ArrayAccess { + use FormTrait; + private \DOMElement $button; private FormFieldRegistry $fields; @@ -51,22 +53,6 @@ public function getFormNode(): \DOMElement return $this->node; } - /** - * Sets the value of the fields. - * - * @param array $values An array of field values - * - * @return $this - */ - public function setValues(array $values): static - { - foreach ($values as $name => $value) { - $this->fields->set($name, $value); - } - - return $this; - } - /** * Gets the field values. * @@ -204,7 +190,7 @@ protected function getRawUri(): string return $this->button->getAttribute('formaction'); } - return $this->node->getAttribute('action'); + return $this->node->getAttribute('action') ?? ''; } /** @@ -223,7 +209,7 @@ public function getMethod(): string return strtoupper($this->button->getAttribute('formmethod')); } - return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET'; + return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method') ?? '') : 'GET'; } /** @@ -236,22 +222,6 @@ public function getName(): string return $this->node->getAttribute('name'); } - /** - * Returns true if the named field exists. - */ - public function has(string $name): bool - { - return $this->fields->has($name); - } - - /** - * Removes a field from the form. - */ - public function remove(string $name): void - { - $this->fields->remove($name); - } - /** * Gets a named field. * @@ -272,26 +242,6 @@ public function set(FormField $field): void $this->fields->add($field); } - /** - * Gets all fields. - * - * @return FormField[] - */ - public function all(): array - { - return $this->fields->all(); - } - - /** - * Returns true if the named field exists. - * - * @param string $name The field name - */ - public function offsetExists(mixed $name): bool - { - return $this->has($name); - } - /** * Gets the value of a field. * @@ -306,29 +256,6 @@ public function offsetGet(mixed $name): FormField|array return $this->fields->get($name); } - /** - * Sets the value of a field. - * - * @param string $name The field name - * @param string|array $value The value of the field - * - * @throws \InvalidArgumentException if the field does not exist - */ - public function offsetSet(mixed $name, mixed $value): void - { - $this->fields->set($name, $value); - } - - /** - * Removes a field from the form. - * - * @param string $name The field name - */ - public function offsetUnset(mixed $name): void - { - $this->fields->remove($name); - } - /** * Disables validation. * diff --git a/src/Symfony/Component/DomCrawler/FormFieldRegistry.php b/src/Symfony/Component/DomCrawler/FormFieldRegistry.php index ef8b644a61fa4..88fb621605932 100644 --- a/src/Symfony/Component/DomCrawler/FormFieldRegistry.php +++ b/src/Symfony/Component/DomCrawler/FormFieldRegistry.php @@ -20,8 +20,7 @@ */ class FormFieldRegistry { - private array $fields = []; - private string $base = ''; + use FormFieldRegistryTrait; /** * Adds a field to the registry. @@ -45,23 +44,6 @@ public function add(FormField $field): void $target = $field; } - /** - * Removes a field based on the fully qualified name and its children from the registry. - */ - public function remove(string $name): void - { - $segments = $this->getSegments($name); - $target = &$this->fields; - while (\count($segments) > 1) { - $path = array_shift($segments); - if (!\is_array($target) || !\array_key_exists($path, $target)) { - return; - } - $target = &$target[$path]; - } - unset($target[array_shift($segments)]); - } - /** * Returns the value of the field based on the fully qualified name and its children. * @@ -84,20 +66,6 @@ public function &get(string $name): FormField|array return $target; } - /** - * Tests whether the form has the given field based on the fully qualified name. - */ - public function has(string $name): bool - { - try { - $this->get($name); - - return true; - } catch (\InvalidArgumentException) { - return false; - } - } - /** * Set the value of a field based on the fully qualified name and its children. * @@ -119,57 +87,4 @@ public function set(string $name, mixed $value): void throw new \InvalidArgumentException(\sprintf('Cannot set value on a compound field "%s".', $name)); } } - - /** - * Returns the list of field with their value. - * - * @return FormField[] The list of fields as [string] Fully qualified name => (mixed) value) - */ - public function all(): array - { - return $this->walk($this->fields, $this->base); - } - - /** - * Transforms a PHP array in a list of fully qualified name / value. - */ - private function walk(array $array, ?string $base = '', array &$output = []): array - { - foreach ($array as $k => $v) { - $path = $base ? \sprintf('%s[%s]', $base, $k) : $k; - if (\is_array($v)) { - $this->walk($v, $path, $output); - } else { - $output[$path] = $v; - } - } - - return $output; - } - - /** - * Splits a field name into segments as a web browser would do. - * - * getSegments('base[foo][3][]') = ['base', 'foo, '3', '']; - * - * @return string[] - */ - private function getSegments(string $name): array - { - if (preg_match('/^(?P[^[]+)(?P(\[.*)|$)/', $name, $m)) { - $segments = [$m['base']]; - while (!empty($m['extra'])) { - $extra = $m['extra']; - if (preg_match('/^\[(?P.*?)\](?P.*)$/', $extra, $m)) { - $segments[] = $m['segment']; - } else { - $segments[] = $extra; - } - } - - return $segments; - } - - return [$name]; - } } diff --git a/src/Symfony/Component/DomCrawler/FormFieldRegistryTrait.php b/src/Symfony/Component/DomCrawler/FormFieldRegistryTrait.php new file mode 100644 index 0000000000000..bfc684bef8602 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/FormFieldRegistryTrait.php @@ -0,0 +1,109 @@ + + * + * 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\DomCrawler\Field\FormField; + +/** + * @author Alexandre Daubois + * + * @internal + */ +trait FormFieldRegistryTrait +{ + private array $fields = []; + private string $base = ''; + + /** + * Removes a field based on the fully qualified name and its children from the registry. + */ + public function remove(string $name): void + { + $segments = $this->getSegments($name); + $target = &$this->fields; + while (\count($segments) > 1) { + $path = array_shift($segments); + if (!\is_array($target) || !\array_key_exists($path, $target)) { + return; + } + $target = &$target[$path]; + } + unset($target[array_shift($segments)]); + } + + /** + * Tests whether the form has the given field based on the fully qualified name. + */ + public function has(string $name): bool + { + try { + $this->get($name); + + return true; + } catch (\InvalidArgumentException) { + return false; + } + } + + /** + * Returns the list of field with their value. + * + * @return FormField[] The list of fields as [string] Fully qualified name => (mixed) value) + */ + public function all(): array + { + return $this->walk($this->fields, $this->base); + } + + /** + * Transforms a PHP array in a list of fully qualified name / value. + */ + private function walk(array $array, ?string $base = '', array &$output = []): array + { + foreach ($array as $k => $v) { + $path = $base ? \sprintf('%s[%s]', $base, $k) : $k; + if (\is_array($v)) { + $this->walk($v, $path, $output); + } else { + $output[$path] = $v; + } + } + + return $output; + } + + /** + * Splits a field name into segments as a web browser would do. + * + * getSegments('base[foo][3][]') = ['base', 'foo, '3', '']; + * + * @return string[] + */ + private function getSegments(string $name): array + { + if (preg_match('/^(?P[^[]+)(?P(\[.*)|$)/', $name, $m)) { + $segments = [$m['base']]; + while (!empty($m['extra'])) { + $extra = $m['extra']; + if (preg_match('/^\[(?P.*?)\](?P.*)$/', $extra, $m)) { + $segments[] = $m['segment']; + } else { + $segments[] = $extra; + } + } + + return $segments; + } + + return [$name]; + } +} diff --git a/src/Symfony/Component/DomCrawler/FormTrait.php b/src/Symfony/Component/DomCrawler/FormTrait.php new file mode 100644 index 0000000000000..604e6c60f38c5 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/FormTrait.php @@ -0,0 +1,206 @@ + + * + * 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\DomCrawler\Field\FormField; + +trait FormTrait +{ + /** + * Sets the value of the fields. + * + * @param array $values An array of field values + * + * @return $this + */ + public function setValues(array $values): static + { + foreach ($values as $name => $value) { + $this->fields->set($name, $value); + } + + return $this; + } + + /** + * Gets the field values as PHP. + * + * This method converts fields with the array notation + * (like foo[bar] to arrays) like PHP does. + */ + public function getPhpValues(): array + { + $values = []; + foreach ($this->getValues() as $name => $value) { + $qs = http_build_query([$name => $value], '', '&'); + if ($qs) { + parse_str($qs, $expandedValue); + $varName = substr($name, 0, \strlen(key($expandedValue))); + $values[] = [$varName => current($expandedValue)]; + } + } + + return array_replace_recursive([], ...$values); + } + + /** + * Gets the file field values as PHP. + * + * This method converts fields with the array notation + * (like foo[bar] to arrays) like PHP does. + * The returned array is consistent with the array for field values + * (@see getPhpValues), rather than uploaded files found in $_FILES. + * For a compound file field foo[bar] it will create foo[bar][name], + * instead of foo[name][bar] which would be found in $_FILES. + */ + public function getPhpFiles(): array + { + $values = []; + foreach ($this->getFiles() as $name => $value) { + $qs = http_build_query([$name => $value], '', '&'); + if ($qs) { + parse_str($qs, $expandedValue); + $varName = substr($name, 0, \strlen(key($expandedValue))); + + array_walk_recursive( + $expandedValue, + function (&$value, $key) { + if (ctype_digit($value) && ('size' === $key || 'error' === $key)) { + $value = (int) $value; + } + } + ); + + reset($expandedValue); + + $values[] = [$varName => current($expandedValue)]; + } + } + + return array_replace_recursive([], ...$values); + } + + /** + * Gets the URI of the form. + * + * The returned URI is not the same as the form "action" attribute. + * This method merges the value if the method is GET to mimics + * browser behavior. + */ + public function getUri(): string + { + $uri = parent::getUri(); + + if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { + $currentParameters = []; + if ($query = parse_url($uri, \PHP_URL_QUERY)) { + parse_str($query, $currentParameters); + } + + $queryString = http_build_query(array_merge($currentParameters, $this->getValues()), '', '&'); + + $pos = strpos($uri, '?'); + $base = false === $pos ? $uri : substr($uri, 0, $pos); + $uri = rtrim($base.'?'.$queryString, '?'); + } + + return $uri; + } + + 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')) { + return $this->button->getAttribute('formaction'); + } + + return $this->node->getAttribute('action') ?? ''; + } + + /** + * Gets the form method. + * + * If no method is defined in the form, GET is returned. + */ + public function getMethod(): string + { + if (null !== $this->method) { + return $this->method; + } + + // 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')) { + return strtoupper($this->button->getAttribute('formmethod')); + } + + return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET'; + } + + /** + * Returns true if the named field exists. + */ + public function has(string $name): bool + { + return $this->fields->has($name); + } + + /** + * Removes a field from the form. + */ + public function remove(string $name): void + { + $this->fields->remove($name); + } + + /** + * Gets all fields. + * + * @return FormField[] + */ + public function all(): array + { + return $this->fields->all(); + } + + /** + * Returns true if the named field exists. + * + * @param string $name The field name + */ + public function offsetExists(mixed $name): bool + { + return $this->has($name); + } + + /** + * Sets the value of a field. + * + * @param string $name The field name + * @param string|array $value The value of the field + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function offsetSet(mixed $name, mixed $value): void + { + $this->fields->set($name, $value); + } + + /** + * Removes a field from the form. + * + * @param string $name The field name + */ + public function offsetUnset(mixed $name): void + { + $this->fields->remove($name); + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/AbstractUriElement.php b/src/Symfony/Component/DomCrawler/NativeCrawler/AbstractUriElement.php new file mode 100644 index 0000000000000..e82a5e6c0ffc9 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/AbstractUriElement.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler; + +use Symfony\Component\DomCrawler\UriResolver; + +/** + * Any HTML element that can link to an URI. + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +abstract class AbstractUriElement +{ + protected \DOM\Element $node; + protected ?string $method; + + /** + * @param \DOM\Element $node 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( + \DOM\Element $node, + protected ?string $currentUri = null, + ?string $method = 'GET', + ) { + $this->setNode($node); + $this->method = $method ? strtoupper($method) : null; + + $elementUriIsRelative = null === parse_url(trim($this->getRawUri()), \PHP_URL_SCHEME); + $baseUriIsAbsolute = null !== $this->currentUri && \in_array(strtolower(substr($this->currentUri, 0, 4)), ['http', 'file']); + if ($elementUriIsRelative && !$baseUriIsAbsolute) { + throw new \InvalidArgumentException(\sprintf('The URL of the element is relative, so you must define its base URI passing an absolute URL to the constructor of the "%s" class ("%s" was passed).', __CLASS__, $this->currentUri)); + } + } + + public function getNode(): \DOM\Element + { + return $this->node; + } + + /** + * Gets the method associated with this link. + */ + public function getMethod(): string + { + return $this->method ?? 'GET'; + } + + /** + * Gets the URI associated with this link. + */ + public function getUri(): string + { + return UriResolver::resolve($this->getRawUri(), $this->currentUri); + } + + /** + * Returns raw URI data. + */ + abstract protected function getRawUri(): string; + + /** + * Returns the canonicalized URI path (see RFC 3986, section 5.2.4). + * + * @param string $path URI path + */ + protected function canonicalizePath(string $path): string + { + if ('' === $path || '/' === $path) { + return $path; + } + + if (str_ends_with($path, '.')) { + $path .= '/'; + } + + $output = []; + + foreach (explode('/', $path) as $segment) { + if ('..' === $segment) { + array_pop($output); + } elseif ('.' !== $segment) { + $output[] = $segment; + } + } + + return implode('/', $output); + } + + /** + * Sets current \DOM\Element instance. + * + * @param \DOM\Element $node A \DOM\Element instance + * + * @throws \LogicException If given node is not an anchor + */ + abstract protected function setNode(\DOM\Element $node): void; +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/DomCrawler.php b/src/Symfony/Component/DomCrawler/NativeCrawler/DomCrawler.php new file mode 100644 index 0000000000000..aba04e96cf982 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/DomCrawler.php @@ -0,0 +1,596 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler; + +use Symfony\Component\DomCrawler\CrawlerTrait; + +/** + * Crawler eases navigation of a list of \DOM\Node objects. + * + * @author Fabien Potencier + * @author Alexandre Daubois + * + * @implements \IteratorAggregate + */ +final class DomCrawler implements \Countable, \IteratorAggregate +{ + use CrawlerTrait; + + private ?\DOM\Document $document = null; + + /** + * @var list<\DOM\Node> + */ + private array $nodes = []; + + /** + * @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, + ) { + if (\PHP_VERSION_ID < 80400) { + throw new \LogicException('The DomCrawler class requires PHP 8.4 or higher.'); + } + + $this->baseHref = $baseHref ?: $uri; + $this->cachedNamespaces = new \ArrayObject(); + + $this->add($node); + } + + /** + * 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 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 \DOM\Node 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 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 \DOM\NodeList, + * 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; + } + + /** + * 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; + } + + 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, \DOM\HTML_NO_DEFAULT_NS, $charset); + } + + 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; + } + + /** + * @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); + } + } + } + + /** + * Creates a crawler for some subnodes. + * + * @param \DOM\NodeList|\DOM\Node|\DOM\Node[]|string|null $nodes + */ + private function createSubCrawler(\DOM\NodeList|\DOM\Node|array|string|null $nodes): static + { + $crawler = new static($nodes, $this->uri, $this->baseHref); + $crawler->isHtml = $this->isHtml; + $crawler->document = $this->document; + $crawler->namespaces = $this->namespaces; + $crawler->cachedNamespaces = $this->cachedNamespaces; + + return $crawler; + } + + /** + * 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); + } + + private function canParseHtml5String(string $content): bool + { + if (false === ($pos = stripos($content, ''))) { + return false; + } + + $header = substr($content, 0, $pos); + + return '' === $header || $this->isValidHtml5Heading($header); + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Field/ChoiceFormField.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/ChoiceFormField.php new file mode 100644 index 0000000000000..dd759c20febf6 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/ChoiceFormField.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\Field\ChoiceFormFieldTrait; + +/** + * ChoiceFormField represents a choice form field. + * + * It is constructed from an HTML select tag, or an HTML checkbox, or radio inputs. + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class ChoiceFormField extends FormField +{ + use ChoiceFormFieldTrait; + + /** + * Adds a choice to the current ones. + * + * @throws \LogicException When choice provided is not multiple nor radio + * + * @internal + */ + public function addChoice(\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)); + } + + $option = $this->buildOptionValue($node); + $this->options[] = $option; + + if ($node->hasAttribute('checked')) { + $this->value = $option['value']; + } + } + + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize(): void + { + $nodeName = strtolower($this->node->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' === $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'))); + } + + $this->value = null; + $this->options = []; + $this->multiple = false; + + if ('input' == $nodeName) { + $this->type = strtolower($this->node->getAttribute('type')); + $optionValue = $this->buildOptionValue($this->node); + $this->options[] = $optionValue; + + if ($this->node->hasAttribute('checked')) { + $this->value = $optionValue['value']; + } + } else { + $this->type = 'select'; + if ($this->node->hasAttribute('multiple')) { + $this->multiple = true; + $this->value = []; + $this->name = str_replace('[]', '', $this->name); + } + + $found = false; + foreach ($this->node->childNodes as $option) { + if ('option' !== strtolower($option->nodeName)) { + continue; + } + + $optionValue = $this->buildOptionValue($option); + $this->options[] = $optionValue; + + if ($option->hasAttribute('selected')) { + $found = true; + if ($this->multiple) { + $this->value[] = $optionValue['value']; + } else { + $this->value = $optionValue['value']; + } + } + } + + // if no option is selected and if it is a simple select box, take the first option as the value + if (!$found && !$this->multiple && $this->options) { + $this->value = $this->options[0]['value']; + } + } + } + + /** + * Returns option value with associated disabled flag. + */ + private function buildOptionValue(\DOM\Element $node): array + { + $option = []; + + $defaultDefaultValue = 'select' === strtolower($this->node->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'); + + return $option; + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FileFormField.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FileFormField.php new file mode 100644 index 0000000000000..d39ea1e50fb34 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FileFormField.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\Field\FileFormFieldTrait; + +/** + * FileFormField represents a file form field (an HTML file input tag). + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class FileFormField extends FormField +{ + use FileFormFieldTrait; +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FormField.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FormField.php new file mode 100644 index 0000000000000..3cea6612453e8 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/FormField.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\Field\FormFieldTrait; + +/** + * FormField is the abstract class for all form fields. + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +abstract class FormField +{ + use FormFieldTrait; + + protected \DOM\Document $document; + protected \DOM\XPath $xpath; + + /** + * @param \DOM\Element $node The node associated with this field + */ + public function __construct( + protected \DOM\Element $node, + ) { + $this->name = $node->getAttribute('name') ?? ''; + $this->xpath = new \DOM\XPath($node->ownerDocument); + + $this->initialize(); + } + + /** + * Returns the label tag associated to the field or null if none. + */ + public function getLabel(): ?\DOM\Element + { + $xpath = new \DOM\XPath($this->node->ownerDocument); + + if ($this->node->hasAttribute('id')) { + $labels = $xpath->query(\sprintf('descendant::label[@for="%s"]', $this->node->getAttribute('id'))); + if ($labels->length > 0) { + return $labels->item(0); + } + } + + $labels = $xpath->query('ancestor::label[1]', $this->node); + + return $labels->length > 0 ? $labels->item(0) : null; + } + + /** + * Check if the current field is disabled. + */ + public function isDisabled(): bool + { + return $this->node->hasAttribute('disabled'); + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Field/InputFormField.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/InputFormField.php new file mode 100644 index 0000000000000..bfc0f38dea11c --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/InputFormField.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\Field\InputFormFieldTrait; + +/** + * InputFormField represents an input form field (an HTML input tag). + * + * For inputs with type of file, checkbox, or radio, there are other more + * specialized classes (cf. FileFormField and ChoiceFormField). + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class InputFormField extends FormField +{ + use InputFormFieldTrait; +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Field/TextareaFormField.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/TextareaFormField.php new file mode 100644 index 0000000000000..fa672a257fa76 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Field/TextareaFormField.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler\Field; + +use Symfony\Component\DomCrawler\Field\TextareaFormFieldTrait; + +/** + * TextareaFormField represents a textarea form field (an HTML textarea tag). + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class TextareaFormField extends FormField +{ + use TextareaFormFieldTrait; +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Form.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Form.php new file mode 100644 index 0000000000000..a51b2a99f5bc4 --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Form.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler; + +use Symfony\Component\DomCrawler\FormTrait; +use Symfony\Component\DomCrawler\NativeCrawler\Field\ChoiceFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\FileFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\FormField as NativeFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\InputFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\TextareaFormField; + +/** + * Form represents an HTML form. + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class Form extends Link implements \ArrayAccess +{ + use FormTrait; + + private \DOM\Element $button; + private FormFieldRegistry $fields; + + /** + * @param \DOM\Element $node A \DOM\Element 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( + \DOM\Element $node, + ?string $currentUri = null, + ?string $method = null, + private ?string $baseHref = null, + ) { + parent::__construct($node, $currentUri, $method); + + $this->initialize(); + } + + /** + * Gets the form node associated with this form. + */ + public function getFormNode(): \DOM\Element + { + return $this->node; + } + + /** + * Gets the field values. + * + * The returned array does not include file fields (@see getFiles). + */ + public function getValues(): array + { + $values = []; + foreach ($this->fields->all() as $name => $field) { + if ($field->isDisabled()) { + continue; + } + + if (!$field instanceof FileFormField && $field->hasValue()) { + $values[$name] = $field->getValue(); + } + } + + return $values; + } + + /** + * Gets the file field values. + */ + public function getFiles(): array + { + if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { + return []; + } + + $files = []; + + foreach ($this->fields->all() as $name => $field) { + if ($field->isDisabled()) { + continue; + } + + if ($field instanceof FileFormField) { + $files[$name] = $field->getValue(); + } + } + + return $files; + } + + /** + * Gets the form name. + * + * If no name is defined on the form, an empty string is returned. + */ + public function getName(): string + { + return $this->node->getAttribute('name') ?? ''; + } + + /** + * Gets a named field. + * + * @return NativeFormField|NativeFormField[]|NativeFormField[][] + * + * @throws \InvalidArgumentException When field is not present in this form + */ + public function get(string $name): NativeFormField|array + { + return $this->fields->get($name); + } + + /** + * Sets a named field. + */ + public function set(NativeFormField $field): void + { + $this->fields->add($field); + } + + /** + * Gets all fields. + * + * @return NativeFormField[] + */ + public function all(): array + { + return $this->fields->all(); + } + + /** + * Gets the value of a field. + * + * @param string $name The field name + * + * @return NativeFormField|NativeFormField[]|NativeFormField[][] + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function offsetGet(mixed $name): NativeFormField|array + { + return $this->fields->get($name); + } + + /** + * Disables validation. + * + * @return $this + */ + public function disableValidation(): static + { + foreach ($this->fields->all() as $field) { + if ($field instanceof ChoiceFormField) { + $field->disableValidation(); + } + } + + return $this; + } + + /** + * Sets the node for the form. + * + * Expects a 'submit' button \DOM\Element and finds the corresponding form element, or the form element itself. + * + * @throws \LogicException If given node is not a button or input or does not have a form ancestor + */ + protected function setNode(\DOM\Element $node): void + { + $this->button = $node; + $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'); + $form = $node->ownerDocument->getElementById($formId); + if (null === $form) { + throw new \LogicException(\sprintf('The selected node has an invalid form attribute (%s).', $formId)); + } + $this->node = $form; + + return; + } + // we loop until we find a form ancestor + do { + if (null === $node = $node->parentNode) { + throw new \LogicException('The selected node does not have a form ancestor.'); + } + } while ('form' !== strtolower($node->nodeName)); + } elseif ('form' !== $nodeName) { + throw new \LogicException(\sprintf('Unable to submit on a "%s" tag.', $nodeName)); + } + + $this->node = $node; + } + + /** + * Adds form elements related to this form. + * + * Creates an internal copy of the submitted 'button' element and + * the form node or the entire document depending on whether we need + * to find non-descendant elements through HTML5 'form' attribute. + */ + private function initialize(): void + { + $this->fields = new FormFieldRegistry(); + $xpath = new \DOM\XPath($this->node->ownerDocument); + + $buttonNodeName = strtolower($this->button->nodeName); + // add submitted button if it has a valid name + 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'); + + // temporarily change the name of the input node for the x coordinate + $this->button->setAttribute('name', $name.'.x'); + $this->set(new InputFormField($this->button)); + + // temporarily change the name of the input node for the y coordinate + $this->button->setAttribute('name', $name.'.y'); + $this->set(new InputFormField($this->button)); + + // restore the original name of the input node + $this->button->setAttribute('name', $name); + } else { + $this->set(new InputFormField($this->button)); + } + } + + // find form elements corresponding to the current form + if ($this->node->hasAttribute('id')) { + // corresponding elements are either descendants or have a matching HTML5 form attribute + $formId = DomCrawler::xpathLiteral($this->node->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)); + foreach ($fieldNodes as $node) { + $this->addField($node); + } + } else { + // do the xpath query with $this->node 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); + foreach ($fieldNodes as $node) { + $this->addField($node); + } + } + + if ($this->baseHref && '' !== ($this->node->getAttribute('action') ?? '')) { + $this->currentUri = $this->baseHref; + } + } + + private function addField(\DOM\Element $node): void + { + if (!$node->hasAttribute('name') || !$node->getAttribute('name')) { + return; + } + + $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'))) { + // there may be other fields with the same name that are no choice + // fields already registered (see https://github.com/symfony/symfony/issues/11689) + if ($this->has($node->getAttribute('name')) && $this->get($node->getAttribute('name')) instanceof ChoiceFormField) { + $this->get($node->getAttribute('name'))->addChoice($node); + } else { + $this->set(new ChoiceFormField($node)); + } + } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type') ?? '')) { + $this->set(new FileFormField($node)); + } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type') ?? ''), ['submit', 'button', 'image'])) { + $this->set(new InputFormField($node)); + } elseif ('textarea' == $nodeName) { + $this->set(new TextareaFormField($node)); + } + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/FormFieldRegistry.php b/src/Symfony/Component/DomCrawler/NativeCrawler/FormFieldRegistry.php new file mode 100644 index 0000000000000..f286151f2102b --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/FormFieldRegistry.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\NativeCrawler; + +use Symfony\Component\DomCrawler\FormFieldRegistryTrait; +use Symfony\Component\DomCrawler\NativeCrawler\Field\ChoiceFormField; +use Symfony\Component\DomCrawler\NativeCrawler\Field\FormField; + +/** + * @author Alexandre Daubois + * + * @internal + */ +class FormFieldRegistry +{ + use FormFieldRegistryTrait; + + /** + * Adds a field to the registry. + */ + public function add(FormField $field): void + { + $segments = $this->getSegments($field->getName()); + + $target = &$this->fields; + while ($segments) { + if (!\is_array($target)) { + $target = []; + } + $path = array_shift($segments); + if ('' === $path) { + $target = &$target[]; + } else { + $target = &$target[$path]; + } + } + $target = $field; + } + + /** + * Returns the value of the field based on the fully qualified name and its children. + * + * @return FormField|FormField[]|FormField[][] + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function &get(string $name): FormField|array + { + $segments = $this->getSegments($name); + $target = &$this->fields; + while ($segments) { + $path = array_shift($segments); + if (!\is_array($target) || !\array_key_exists($path, $target)) { + throw new \InvalidArgumentException(\sprintf('Unreachable field "%s".', $path)); + } + $target = &$target[$path]; + } + + return $target; + } + + /** + * Set the value of a field based on the fully qualified name and its children. + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function set(string $name, mixed $value): void + { + $target = &$this->get($name); + if ((!\is_array($value) && $target instanceof FormField) || $target instanceof ChoiceFormField) { + $target->setValue($value); + } elseif (\is_array($value)) { + $registry = new static(); + $registry->base = $name; + $registry->fields = $value; + foreach ($registry->all() as $k => $v) { + $this->set($k, $v); + } + } else { + throw new \InvalidArgumentException(\sprintf('Cannot set value on a compound field "%s".', $name)); + } + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Image.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Image.php new file mode 100644 index 0000000000000..a66bdd66f85ac --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Image.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler; + +/** + * Image represents an HTML image (an HTML img tag). + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class Image extends AbstractUriElement +{ + public function __construct(\DOM\Element $node, ?string $currentUri = null) + { + parent::__construct($node, $currentUri, 'GET'); + } + + protected function getRawUri(): string + { + return $this->node->getAttribute('src') ?? ''; + } + + protected function setNode(\DOM\Element $node): void + { + if ('img' !== strtolower($node->nodeName)) { + throw new \LogicException(\sprintf('Unable to visualize a "%s" tag.', $node->nodeName)); + } + + $this->node = $node; + } +} diff --git a/src/Symfony/Component/DomCrawler/NativeCrawler/Link.php b/src/Symfony/Component/DomCrawler/NativeCrawler/Link.php new file mode 100644 index 0000000000000..7a0d5ed4fe16e --- /dev/null +++ b/src/Symfony/Component/DomCrawler/NativeCrawler/Link.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\NativeCrawler; + +/** + * Link represents an HTML link (an HTML a, area or link tag). + * + * @author Fabien Potencier + * @author Alexandre Daubois + */ +class Link extends AbstractUriElement +{ + protected function getRawUri(): string + { + return $this->node->getAttribute('href') ?? ''; + } + + protected function setNode(\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; + } +} diff --git a/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php b/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTestCase.php index 97b16b9fe6073..27db5aa74f8ec 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'); @@ -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/NativeCrawler/DomCrawlerTest.php b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/DomCrawlerTest.php new file mode 100644 index 0000000000000..22a2f64f7402a --- /dev/null +++ b/src/Symfony/Component/DomCrawler/Tests/NativeCrawler/DomCrawlerTest.php @@ -0,0 +1,1394 @@ + + * + * 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\DomCrawler; +use Symfony\Component\DomCrawler\NativeCrawler\Form; +use Symfony\Component\DomCrawler\NativeCrawler\Image; +use Symfony\Component\DomCrawler\NativeCrawler\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); + } + + protected static function getCrawlerClass(): string + { + return DomCrawler::class; + } + + public function testConstructorWithModernNode() + { + $crawler = $this->createCrawler(); + $this->assertCount(0, $crawler, '__construct() returns an empty crawler'); + + $node = \DOM\HTMLDocument::createEmpty()->createElement('test'); + + $crawler = $this->createCrawler($node); + $this->assertCount(1, $crawler, '__construct() takes a node as a first argument'); + } + + public function testClearWithModerNode() + { + $node = \DOM\HTMLDocument::createEmpty()->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'); + + $node = \DOM\HTMLDocument::createEmpty()->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() + { + $node = \DOM\XMLDocument::createEmpty()->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(Link::class, $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(Image::class, $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()->getFormNode()->getAttribute('id'), $crawler2->form()->getFormNode()->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/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

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