diff --git a/src/Element/ElementFinder.php b/src/Element/ElementFinder.php index 98e0c9a95..0a45bb1a2 100644 --- a/src/Element/ElementFinder.php +++ b/src/Element/ElementFinder.php @@ -11,6 +11,7 @@ namespace Behat\Mink\Element; use Behat\Mink\Driver\DriverInterface; +use Behat\Mink\Selector\NamedSelectorMode; use Behat\Mink\Selector\SelectorsHandler; use Behat\Mink\Selector\Xpath\Manipulator; @@ -32,12 +33,24 @@ class ElementFinder * @var Manipulator */ private $xpathManipulator; + /** + * @var NamedSelectorMode::* + */ + private $namedSelectorMode; - public function __construct(DriverInterface $driver, SelectorsHandler $selectorsHandler, ?Manipulator $xpathManipulator = null) + /** + * @param NamedSelectorMode::* $namedSelectorMode How the "named" selector is resolved. + */ + public function __construct(DriverInterface $driver, SelectorsHandler $selectorsHandler, ?Manipulator $xpathManipulator = null, string $namedSelectorMode = NamedSelectorMode::PARTIAL_FALLBACK) { + if (NamedSelectorMode::PARTIAL_FALLBACK !== $namedSelectorMode && NamedSelectorMode::EXACT !== $namedSelectorMode) { + throw new \InvalidArgumentException(sprintf('Unknown named selector mode "%s".', $namedSelectorMode)); + } + $this->driver = $driver; $this->selectorsHandler = $selectorsHandler; $this->xpathManipulator = $xpathManipulator ?? new Manipulator(); + $this->namedSelectorMode = $namedSelectorMode; } /** @@ -49,7 +62,7 @@ public function findAll(string $selector, $locator, string $parentXpath) { if ('named' === $selector) { $items = $this->findAll('named_exact', $locator, $parentXpath); - if (empty($items)) { + if (empty($items) && NamedSelectorMode::EXACT !== $this->namedSelectorMode) { $items = $this->findAll('named_partial', $locator, $parentXpath); } diff --git a/src/Selector/NamedSelectorMode.php b/src/Selector/NamedSelectorMode.php new file mode 100644 index 000000000..d8ceacbe4 --- /dev/null +++ b/src/Selector/NamedSelectorMode.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 Behat\Mink\Selector; + +/** + * How the "named" selector resolves a locator. + * + * This is a placeholder for an enum, which the minimum PHP version does not allow yet. + */ +final class NamedSelectorMode +{ + /** + * Try an exact match first, and fall back to a partial one when it finds nothing. + */ + public const PARTIAL_FALLBACK = 'partial_fallback'; + + /** + * Only match exactly, the way the "named_exact" selector does. + */ + public const EXACT = 'exact'; + + /** + * @codeCoverageIgnore + */ + private function __construct() + { + } +} diff --git a/src/Session.php b/src/Session.php index 78e9f12fc..be89b3371 100644 --- a/src/Session.php +++ b/src/Session.php @@ -12,6 +12,7 @@ use Behat\Mink\Driver\DriverInterface; use Behat\Mink\Element\ElementFinder; +use Behat\Mink\Selector\NamedSelectorMode; use Behat\Mink\Selector\SelectorsHandler; use Behat\Mink\Element\DocumentElement; @@ -39,11 +40,14 @@ class Session */ private $selectorsHandler; - public function __construct(DriverInterface $driver, ?SelectorsHandler $selectorsHandler = null) + /** + * @param NamedSelectorMode::* $namedSelectorMode How the "named" selector is resolved. + */ + public function __construct(DriverInterface $driver, ?SelectorsHandler $selectorsHandler = null, string $namedSelectorMode = NamedSelectorMode::PARTIAL_FALLBACK) { $this->driver = $driver; $this->selectorsHandler = $selectorsHandler ?? new SelectorsHandler(); - $this->elementFinder = new ElementFinder($driver, $this->selectorsHandler); + $this->elementFinder = new ElementFinder($driver, $this->selectorsHandler, null, $namedSelectorMode); $this->page = new DocumentElement($this); $driver->setSession($this); diff --git a/tests/Element/ElementFinderTest.php b/tests/Element/ElementFinderTest.php index 97c254403..2b81ec4b7 100644 --- a/tests/Element/ElementFinderTest.php +++ b/tests/Element/ElementFinderTest.php @@ -5,6 +5,7 @@ use Behat\Mink\Driver\DriverInterface; use Behat\Mink\Element\ElementFinder; use Behat\Mink\Element\NodeElement; +use Behat\Mink\Selector\NamedSelectorMode; use Behat\Mink\Selector\SelectorsHandler; use Behat\Mink\Selector\Xpath\Manipulator; use PHPUnit\Framework\MockObject\MockObject; @@ -159,4 +160,50 @@ public function testNamedPartialFallback() $this->assertEquals('element1', $results[0]->getXpath()); $this->assertEquals('element2', $results[1]->getXpath()); } + + public function testNamedExactModeDoesNotFallBackToPartial() + { + $finder = new ElementFinder($this->driver, $this->selectorsHandler, $this->manipulator, NamedSelectorMode::EXACT); + + $this->selectorsHandler->expects($this->once()) + ->method('selectorToXpath') + ->with('named_exact', 'test') + ->will($this->returnValue('named_xpath')); + + $this->manipulator->expects($this->once()) + ->method('prepend') + ->with('named_xpath', 'parent_xpath') + ->will($this->returnValue('full_xpath')); + + $this->driver->expects($this->once()) + ->method('find') + ->with('full_xpath') + ->will($this->returnValue(array())); + + $this->assertEquals(array(), $finder->findAll('named', 'test', 'parent_xpath')); + } + + /** + * @dataProvider provideInvalidNamedSelectorModes + */ + public function testUnknownNamedSelectorModeIsRejected(string $namedSelectorMode) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Unknown named selector mode "%s".', $namedSelectorMode)); + + // @phpstan-ignore argument.type (this test exercises the runtime guard, on values the narrowed type forbids) + new ElementFinder($this->driver, $this->selectorsHandler, $this->manipulator, $namedSelectorMode); + } + + /** + * @return array + */ + public static function provideInvalidNamedSelectorModes() + { + return array( + 'unknown value' => array('nope'), + 'empty string' => array(''), + 'the selector name itself' => array('named_exact'), + ); + } } diff --git a/tests/SessionTest.php b/tests/SessionTest.php index b2dabf76a..67c9a4c04 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -3,6 +3,8 @@ namespace Behat\Mink\Tests; use Behat\Mink\Driver\DriverInterface; +use Behat\Mink\Element\ElementFinder; +use Behat\Mink\Selector\NamedSelectorMode; use Behat\Mink\Selector\SelectorsHandler; use Behat\Mink\Session; use PHPUnit\Framework\MockObject\MockObject; @@ -355,4 +357,42 @@ public function testMaximizeWindow() $this->session->maximizeWindow('test'); } + + public function testNamedSelectorModeIsForwardedToTheElementFinder() + { + $selectorsHandler = $this->createMock(SelectorsHandler::class); + $selectorsHandler->expects($this->once()) + ->method('selectorToXpath') + ->with('named_exact', 'test') + ->willReturn('named_xpath'); + + // a partial fallback would query the driver a second time + $this->driver->expects($this->once()) + ->method('find') + ->willReturn(array()); + + $session = new Session($this->driver, $selectorsHandler, NamedSelectorMode::EXACT); + + $this->assertSame(array(), $session->getPage()->findAll('named', 'test')); + } + + public function testTheDefaultNamedSelectorModeStillFallsBackToPartial() + { + $selectorsHandler = $this->createMock(SelectorsHandler::class); + $selectorsHandler->expects($this->exactly(2)) + ->method('selectorToXpath') + ->willReturnMap(array( + array('named_exact', 'test', 'named_xpath'), + array('named_partial', 'test', 'partial_xpath'), + )); + + // the exact lookup finds nothing, so the partial one is queried as well + $this->driver->expects($this->exactly(2)) + ->method('find') + ->willReturn(array()); + + $session = new Session($this->driver, $selectorsHandler); + + $this->assertSame(array(), $session->getPage()->findAll('named', 'test')); + } }