expectException(RuntimeException::class); $this->expectExceptionMessage('Unknown expression type "foo"'); Query::compile('h1', 'foo'); } /** * @dataProvider compileCssTests */ public function testCompileCssSelector($selector, $xpath) { $this->assertEquals($xpath, Query::compile($selector)); } /** * @dataProvider getSegmentsTests * * @param string $selector * @param array $segments */ public function testGetSegments($selector, $segments) { $this->assertEquals($segments, Query::getSegments($selector)); } /** * @dataProvider buildXpathTests * * @param array $segments * @param string $xpath */ public function testBuildXpath($segments, $xpath) { $this->assertEquals($xpath, Query::buildXpath($segments)); } public function testBuildXpathWithEmptyArray() { $this->expectException(InvalidArgumentException::class); Query::buildXpath([]); } public function testCompileWithEmptyXpathExpression() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('The expression must not be empty.'); Query::compile('', Query::TYPE_XPATH); } public function testCompileWithEmptyCssExpression() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('The expression must not be empty.'); Query::compile('', Query::TYPE_CSS); } public function testGetSegmentsWithEmptySelector() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('The selector must not be empty.'); Query::getSegments(''); } public function testEmptyAttributeName() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Invalid selector "input[=foo]": attribute name must not be empty.'); Query::compile('input[=foo]'); } public function testUnknownPseudoClass() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Unknown pseudo-class "unknown-pseudo-class".'); Query::compile('li:unknown-pseudo-class'); } /** * @dataProvider containsInvalidCaseSensitiveParameterDataProvider */ public function testContainsInvalidCaseSensitiveParameter($caseSensitive) { $message = sprintf('Parameter 2 of "contains" pseudo-class must be equal true or false, "%s" given', $caseSensitive); $this->expectException(InvalidSelectorException::class, $message); Query::compile("a:contains('Log in', {$caseSensitive})"); } public function containsInvalidCaseSensitiveParameterDataProvider(): array { return [ ['foo'], ['TRUE'], ['FALSE'], ]; } public function testEmptyNthExpression() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('nth-child (or nth-last-child) expression must not be empty.'); Query::compile('li:nth-child()'); } public function testEmptyProperty() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Invalid property "::".'); Query::compile('li::'); } public function testInvalidProperty() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Unknown property "foo".'); Query::compile('li::foo'); } public function testUnknownNthExpression() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Invalid nth-child expression "foo".'); Query::compile('li:nth-child(foo)'); } public function testGetSegmentsWithEmptyClassName() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Invalid selector ".".'); Query::getSegments('.'); } public function testCompileWithEmptyClassName() { $this->expectException(InvalidSelectorException::class); $this->expectExceptionMessage('Invalid selector ".".'); Query::compile('span.'); } public function testCompileXpath() { $this->assertEquals('//div', Query::compile('//div', Query::TYPE_XPATH)); } public function testSetCompiled() { $xpath = "//*[@id='foo']//*[contains(concat(' ', normalize-space(@class), ' '), ' bar ')]//baz"; $compiled = ['#foo .bar baz' => $xpath]; Query::setCompiled($compiled); $this->assertEquals($compiled, Query::getCompiled()); } public function testGetCompiled() { Query::setCompiled([]); $selector = '#foo .bar baz'; $xpath = '//*[@id="foo"]//*[contains(concat(" ", normalize-space(@class), " "), " bar ")]//baz'; $compiled = [$selector => $xpath]; Query::compile($selector); $this->assertEquals($compiled, Query::getCompiled()); } public function compileCssTests() { $compiled = [ ['a', '//a'], ['foo bar baz', '//foo//bar//baz'], ['foo > bar > baz', '//foo/bar/baz'], ['#foo', '//*[@id="foo"]'], ['.foo', '//*[contains(concat(" ", normalize-space(@class), " "), " foo ")]'], ['.foo.bar', '//*[(contains(concat(" ", normalize-space(@class), " "), " foo ")) and (contains(concat(" ", normalize-space(@class), " "), " bar "))]'], ['*[foo=bar]', '//*[@foo="bar"]'], ['*[foo="bar"]', '//*[@foo="bar"]'], ['*[foo=\'bar\']', '//*[@foo="bar"]'], ['select[name=category] option[selected=selected]', '//select[@name="category"]//option[@selected="selected"]'], ['*[^data-]', '//*[@*[starts-with(name(), "data-")]]'], ['*[^data-=foo]', '//*[@*[starts-with(name(), "data-")]="foo"]'], ['a[href^=https]', '//a[starts-with(@href, "https")]'], ['img[src$=png]', '//img[substring(@src, string-length(@src) - string-length("png") + 1) = "png"]'], ['a[href*=example.com]', '//a[contains(@href, "example.com")]'], ['script[!src]', '//script[not(@src)]'], ['a[href!="http://foo.com/"]', '//a[not(@href="http://foo.com/")]'], ['a[foo~="bar"]', '//a[contains(concat(" ", normalize-space(@foo), " "), " bar ")]'], ['input, textarea, select', '//input|//textarea|//select'], ['input[name="name"], textarea[name="description"], select[name="type"]', '//input[@name="name"]|//textarea[@name="description"]|//select[@name="type"]'], ['li:first-child', '//li[position() = 1]'], ['li:last-child', '//li[position() = last()]'], ['*:not(a[href*="example.com"])', '//*[not(self::a[contains(@href, "example.com")])]'], ['*:not(a[href*="example.com"]):not(.foo)', '//*[(not(self::a[contains(@href, "example.com")])) and (not(self::*[contains(concat(" ", normalize-space(@class), " "), " foo ")]))]'], ['ul:empty', '//ul[count(descendant::*) = 0]'], ['ul:not-empty', '//ul[count(descendant::*) > 0]'], ['li:nth-child(odd)', '//*[(name()="li") and (position() mod 2 = 1 and position() >= 1)]'], ['li:nth-child(even)', '//*[(name()="li") and (position() mod 2 = 0 and position() >= 0)]'], ['li:nth-child(3)', '//*[(name()="li") and (position() = 3)]'], ['li:nth-child(-3)', '//*[(name()="li") and (position() = -3)]'], ['li:nth-child(3n)', '//*[(name()="li") and ((position() + 0) mod 3 = 0 and position() >= 0)]'], ['li:nth-child(3n+1)', '//*[(name()="li") and ((position() - 1) mod 3 = 0 and position() >= 1)]'], ['li:nth-child(3n-1)', '//*[(name()="li") and ((position() + 1) mod 3 = 0 and position() >= 1)]'], ['li:nth-child(n+3)', '//*[(name()="li") and ((position() - 3) mod 1 = 0 and position() >= 3)]'], ['li:nth-child(n-3)', '//*[(name()="li") and ((position() + 3) mod 1 = 0 and position() >= 3)]'], ['li:nth-of-type(odd)', '//li[position() mod 2 = 1 and position() >= 1]'], ['li:nth-of-type(even)', '//li[position() mod 2 = 0 and position() >= 0]'], ['li:nth-of-type(3)', '//li[position() = 3]'], ['li:nth-of-type(-3)', '//li[position() = -3]'], ['li:nth-of-type(3n)', '//li[(position() + 0) mod 3 = 0 and position() >= 0]'], ['li:nth-of-type(3n+1)', '//li[(position() - 1) mod 3 = 0 and position() >= 1]'], ['li:nth-of-type(3n-1)', '//li[(position() + 1) mod 3 = 0 and position() >= 1]'], ['li:nth-of-type(n+3)', '//li[(position() - 3) mod 1 = 0 and position() >= 3]'], ['li:nth-of-type(n-3)', '//li[(position() + 3) mod 1 = 0 and position() >= 3]'], ['ul:has(li.item)', '//ul[.//li[contains(concat(" ", normalize-space(@class), " "), " item ")]]'], ['form[name=register]:has(input[name=foo])', '//form[(@name="register") and (.//input[@name="foo"])]'], ['ul li a::text', '//ul//li//a/text()'], ['ul li a::text()', '//ul//li//a/text()'], ['ul li a::attr(href)', '//ul//li//a/@*[name() = "href"]'], ['ul li a::attr(href, title)', '//ul//li//a/@*[name() = "href" or name() = "title"]'], ['> ul li a', '/ul//li//a'], ]; $compiled = array_merge($compiled, $this->getContainsPseudoClassTests()); $compiled = array_merge($compiled, $this->getPropertiesTests()); $compiled = array_merge($compiled, [ ['a[title="foo, bar::baz"]', '//a[@title="foo, bar::baz"]'], ]); return $compiled; } private function getContainsPseudoClassTests(): array { $strToLowerFunction = function_exists('mb_strtolower') ? 'mb_strtolower' : 'strtolower'; $containsXpath = [ // caseSensitive = true, fullMatch = false ['li:contains(foo)', '//li[contains(text(), "foo")]'], ['li:contains("foo")', '//li[contains(text(), "foo")]'], ['li:contains(\'foo\')', '//li[contains(text(), "foo")]'], // caseSensitive = true, fullMatch = false ['li:contains(foo, true)', '//li[contains(text(), "foo")]'], ['li:contains("foo", true)', '//li[contains(text(), "foo")]'], ['li:contains(\'foo\', true)', '//li[contains(text(), "foo")]'], // caseSensitive = true, fullMatch = false ['li:contains(foo, true, false)', '//li[contains(text(), "foo")]'], ['li:contains("foo", true, false)', '//li[contains(text(), "foo")]'], ['li:contains(\'foo\', true, false)', '//li[contains(text(), "foo")]'], // caseSensitive = true, fullMatch = true ['li:contains(foo, true, true)', '//li[text() = "foo"]'], ['li:contains("foo", true, true)', '//li[text() = "foo"]'], ['li:contains(\'foo\', true, true)', '//li[text() = "foo"]'], // caseSensitive = false, fullMatch = false ['li:contains(foo, false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], ['li:contains("foo", false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], ['li:contains(\'foo\', false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], // caseSensitive = false, fullMatch = false ['li:contains(foo, false, false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], ['li:contains("foo", false, false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], ['li:contains(\'foo\', false, false)', "//li[contains(php:functionString(\"{$strToLowerFunction}\", .), php:functionString(\"{$strToLowerFunction}\", \"foo\"))]"], // caseSensitive = false, fullMatch = true ['li:contains(foo, false, true)', "//li[php:functionString(\"{$strToLowerFunction}\", .) = php:functionString(\"{$strToLowerFunction}\", \"foo\")]"], ['li:contains("foo", false, true)', "//li[php:functionString(\"{$strToLowerFunction}\", .) = php:functionString(\"{$strToLowerFunction}\", \"foo\")]"], ['li:contains(\'foo\', false, true)', "//li[php:functionString(\"{$strToLowerFunction}\", .) = php:functionString(\"{$strToLowerFunction}\", \"foo\")]"], ]; return $containsXpath; } private function getPropertiesTests(): array { return [ ['a::text', '//a/text()'], ['a::text()', '//a/text()'], ['a::attr', '//a/@*'], ['a::attr()', '//a/@*'], ['a::attr(href)', '//a/@*[name() = "href"]'], ['a::attr(href,title)', '//a/@*[name() = "href" or name() = "title"]'], ['a::attr(href, title)', '//a/@*[name() = "href" or name() = "title"]'], ]; } public function buildXpathTests(): array { $xpath = [ '//a', '//*[@id="foo"]', '//a[@id="foo"]', '//a[contains(concat(" ", normalize-space(@class), " "), " foo ")]', '//a[(contains(concat(" ", normalize-space(@class), " "), " foo ")) and (contains(concat(" ", normalize-space(@class), " "), " bar "))]', '//a[@href]', '//a[@href="http://example.com/"]', '//a[(@href="http://example.com/") and (@title="Example Domain")]', '//a[(@target="_blank") and (starts-with(@href, "https"))]', '//a[substring(@href, string-length(@href) - string-length(".com") + 1) = ".com"]', '//a[contains(@href, "example")]', '//a[not(@href="http://foo.com/")]', '//script[not(@src)]', '//li[position() = 1]', '//*[(@id="id") and (contains(concat(" ", normalize-space(@class), " "), " foo ")) and (@name="value") and (position() = 1)]', ]; $segments = [ ['tag' => 'a'], ['id' => 'foo'], ['tag' => 'a', 'id' => 'foo'], ['tag' => 'a', 'classes' => ['foo']], ['tag' => 'a', 'classes' => ['foo', 'bar']], ['tag' => 'a', 'attributes' => ['href' => null]], ['tag' => 'a', 'attributes' => ['href' => 'http://example.com/']], ['tag' => 'a', 'attributes' => ['href' => 'http://example.com/', 'title' => 'Example Domain']], ['tag' => 'a', 'attributes' => ['target' => '_blank', 'href^' => 'https']], ['tag' => 'a', 'attributes' => ['href$' => '.com']], ['tag' => 'a', 'attributes' => ['href*' => 'example']], ['tag' => 'a', 'attributes' => ['href!' => 'http://foo.com/']], ['tag' => 'script', 'attributes' => ['!src' => null]], ['tag' => 'li', 'pseudo' => [['type' => 'first-child', 'expression' => null]]], ['tag' => '*', 'id' => 'id', 'classes' => ['foo'], 'attributes' => ['name' => 'value'], 'pseudo' => [['type' => 'first-child', 'expression' => null]], 'rel' => '>'], ]; $parameters = []; foreach ($segments as $index => $segment) { $parameters[] = [$segment, $xpath[$index]]; } return $parameters; } public function getSegmentsTests(): array { $segments = [ ['selector' => 'a', 'tag' => 'a'], ['selector' => '#foo', 'id' => 'foo'], ['selector' => 'a#foo', 'tag' => 'a', 'id' => 'foo'], ['selector' => 'a.foo', 'tag' => 'a', 'classes' => ['foo']], ['selector' => 'a.foo.bar', 'tag' => 'a', 'classes' => ['foo', 'bar']], ['selector' => 'a[href]', 'tag' => 'a', 'attributes' => ['href' => null]], ['selector' => 'a[href=http://example.com/]', 'tag' => 'a', 'attributes' => ['href' => 'http://example.com/']], ['selector' => 'a[href="http://example.com/"]', 'tag' => 'a', 'attributes' => ['href' => 'http://example.com/']], ['selector' => 'a[href=\'http://example.com/\']', 'tag' => 'a', 'attributes' => ['href' => 'http://example.com/']], ['selector' => 'a[href=http://example.com/][title=Example Domain]', 'tag' => 'a', 'attributes' => ['href' => 'http://example.com/', 'title' => 'Example Domain']], ['selector' => 'a[href=http://example.com/][href=http://example.com/404]', 'tag' => 'a', 'attributes' => ['href' => 'http://example.com/404']], ['selector' => 'a[href^=https]', 'tag' => 'a', 'attributes' => ['href^' => 'https']], ['selector' => 'li:first-child', 'tag' => 'li', 'pseudo' => [['type' => 'first-child', 'expression' => null]]], ['selector' => 'ul >', 'tag' => 'ul', 'rel' => '>'], ['selector' => '#id.foo[name=value]:first-child >', 'id' => 'id', 'classes' => ['foo'], 'attributes' => ['name' => 'value'], 'pseudo' => [['type' => 'first-child', 'expression' => null]], 'rel' => '>'], ['selector' => 'li.bar:nth-child(2n)', 'tag' => 'li', 'classes' => ['bar'], 'pseudo' => [['type' => 'nth-child', 'expression' => '2n']]], ]; $parameters = []; foreach ($segments as $segment) { $parameters[] = [$segment['selector'], $segment]; } return $parameters; } }