diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ab334d9..2fcb0367 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,8 +5,9 @@ on: pull_request: workflow_dispatch: -env: - DRIVER_URL: "http://localhost:4444/wd/hub" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true defaults: run: @@ -43,6 +44,7 @@ jobs: strategy: matrix: php: [ '7.2', '7.3', '7.4', '8.0', '8.1' ] + selenium: ['2.53.1', 'latest'] fail-fast: false steps: @@ -64,6 +66,13 @@ jobs: run: | composer update --no-interaction --prefer-dist + - name: Replace instaclick/php-webdriver with lullabot/php-webdriver for new selenium versions + run: | + if [ "${{ matrix.selenium }}" == "latest" ]; then + # @todo replace this with a release + composer require lullabot/php-webdriver:dev-main + fi + - name: Setup Mink test server run: | mkdir ./logs @@ -71,7 +80,7 @@ jobs: - name: Start Selenium run: | - docker run --net host --name selenium --volume /dev/shm:/dev/shm --shm-size 2g selenium/standalone-firefox:2.53.1 &> ./logs/selenium.log & + docker run --net host --name selenium --volume /dev/shm:/dev/shm --volume $GITHUB_WORKSPACE:$GITHUB_WORKSPACE -e SE_NODE_OVERRIDE_MAX_SESSIONS=true -e SE_NODE_MAX_SESSIONS=5 --shm-size 2g selenium/standalone-firefox:${{ matrix.selenium }} &> ./logs/selenium.log & - name: Wait for browser & PHP to start run: | @@ -80,7 +89,11 @@ jobs: - name: Run tests run: | - vendor/bin/phpunit -v --coverage-clover=coverage.xml + if [ "${{ matrix.selenium }}" == "latest" ]; then + DRIVER_URL="http://localhost:4444" ./vendor/bin/phpunit -v --coverage-clover=coverage.xml + else + ./vendor/bin/phpunit -v --coverage-clover=coverage.xml + fi - name: Upload coverage uses: codecov/codecov-action@v2 diff --git a/phpstan.dist.neon b/phpstan.dist.neon index f1119ce1..f74dd74b 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,6 +9,14 @@ parameters: - '#^Method Behat\\Mink\\Tests\\Driver\\Custom\\[^:]+Test(Case)?\:\:test\w*\(\) has no return type specified\.$#' # instaclick/php-webdriver misses the @method for this magic method - '#^Call to an undefined method WebDriver\\Session\:\:file\(\)\.$#' + # W3C compatability changes + - '#^Call to an undefined method WebDriver\\Session\:\:getWindowHandles\(\)\.$#' + - '#^Call to an undefined method WebDriver\\Session\:\:getWindowHandle\(\)\.$#' + - '#^Call to an undefined method WebDriver\\Session\:\:postActions\(\)\.$#' + - '#^Call to an undefined method WebDriver\\Session\:\:deleteActions\(\)\.$#' + - '#^Call to an undefined method WebDriver\\Session|WebDriver\\Window\\:\:postRect\(\)\.$#' + - '#^Access to undefined constant WebDriver\\Element\:\:WEB_ELEMENT_ID\.$#' + - '#^Call to an undefined method WebDriver\\Element\:\:property\(\)\.$#' includes: - vendor/phpstan/phpstan-phpunit/extension.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f4974da9..61b6582a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,7 @@ + @@ -18,7 +19,7 @@ - + diff --git a/src/Selenium2Driver.php b/src/Selenium2Driver.php index 05d6e321..dee5c3c0 100755 --- a/src/Selenium2Driver.php +++ b/src/Selenium2Driver.php @@ -214,6 +214,19 @@ public static function getDefaultCapabilities() ); } + /** + * Checks if the WebDriver session is W3C compatible. + * + * @return bool + * @throws DriverException + */ + public function isW3C() { + if (method_exists($this->getWebDriverSession(), 'isW3C')) { + return $this->getWebDriverSession()->isW3C(); + } + return false; + } + /** * Makes sure that the Syn event library has been injected into the current page, * and return $this for a fluid interface, @@ -317,10 +330,23 @@ private function executeJsOnElement(Element $element, string $script, bool $sync { $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script); - $options = array( - 'script' => $script, - 'args' => array($element), - ); + if ($this->isW3C()) { + $options = array( + 'script' => $script, + 'args' => [ + [ + 'ELEMENT' => $element->getID(), + Element::WEB_ELEMENT_ID => $element->getID(), + ] + ], + ); + } + else { + $options = [ + 'script' => $script, + 'args' => [['ELEMENT' => $element->getID()]], + ]; + } if ($sync) { return $this->getWebDriverSession()->execute($options); @@ -425,12 +451,41 @@ public function back() public function switchToWindow(?string $name = null) { - $this->getWebDriverSession()->focusWindow($name ?: ''); + if ($this->isW3C()) { + $allHandles = $this->getWebDriverSession()->getWindowHandles(); + foreach ($allHandles as $handle) { + $script = <<getWebDriverSession()->focusWindow($handle); + $windowName = $this->getWebDriverSession()->execute(array('script' => $script, 'args' => array())); + if ($windowName === $name || (empty($name) && empty($windowName))) { + break; + } + } + } + else { + $this->getWebDriverSession()->focusWindow($name ?: ''); + } } public function switchToIFrame(?string $name = null) { - $this->getWebDriverSession()->frame(array('id' => $name)); + if ($this->isW3C()) { + if (empty($name)) { + $this->getWebDriverSession()->frame(array('id' => null)); + } + else { + $frameElement = $this->findElement("//iframe[@name='$name']"); + $this->getWebDriverSession()->frame(array('id' => [ + Element::WEB_ELEMENT_ID => $frameElement->getID(), + ])); + } + } + else { + $this->getWebDriverSession()->frame(array('id' => $name)); + } } public function setCookie(string $name, ?string $value = null) @@ -492,13 +547,20 @@ public function getScreenshot() public function getWindowNames() { + if ($this->isW3C()) { + return $this->getWebDriverSession()->getWindowHandles(); + } + return $this->getWebDriverSession()->window_handles(); } public function getWindowName() { - return $this->getWebDriverSession()->window_handle(); - } + if ($this->isW3C()) { + return $this->getWebDriverSession()->getWindowHandle(); + } + + return $this->getWebDriverSession()->window_handle(); } /** * @protected @@ -600,6 +662,10 @@ public function getValue(string $xpath) return $this->executeJsOnElement($element, $script); } + if ($this->isW3C()) { + return $element->property('value'); + } + return $element->attribute('value'); } @@ -658,7 +724,12 @@ public function setValue(string $xpath, $value) throw new DriverException('Only string values can be used for a file input.'); } - $element->postValue(array('value' => array(strval($value)))); + if ($this->isW3C()) { + $element->postValue(array('text' => $value)); + } + else { + $element->postValue(array('value' => array($value))); + } return; } @@ -671,11 +742,24 @@ public function setValue(string $xpath, $value) $value = strval($value); if (in_array($elementName, array('input', 'textarea'))) { - $existingValueLength = strlen($element->attribute('value')); - $value = str_repeat(Key::BACKSPACE . Key::DELETE, $existingValueLength) . $value; + if ($this->isW3C() && $elementName === 'textarea') { + // Backspace doesn't work for textareas for some reason, and + // sending ->clear() will trigger a change event. + $element->postValue(array('text' => Key::CONTROL . 'a')); + $element->postValue(array('text' => Key::DELETE)); + } + else { + $existingValueLength = strlen($element->attribute('value')); + $value = str_repeat(Key::BACKSPACE . Key::DELETE, $existingValueLength) . $value; + } } - $element->postValue(array('value' => array($value))); + if ($this->isW3C()) { + $element->postValue(array('text' => $value)); + } + else { + $element->postValue(array('value' => array($value))); + } // Remove the focus from the element if the field still has focus in // order to trigger the change event. By doing this instead of simply // triggering the change event for the given xpath we ensure that the @@ -775,14 +859,58 @@ private function clickOnElement(Element $element): void public function doubleClick(string $xpath) { - $this->mouseOver($xpath); - $this->getWebDriverSession()->doubleclick(); + if ($this->isW3C()) { + $actions = array( + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse1', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [ + ['type' => 'pointerMove', 'duration' => 0, 'origin' => [Element::WEB_ELEMENT_ID => $this->findElement($xpath)->getID()], 'x' => 0, 'y' => 0], + ['type' => 'pointerDown', "button" => 0], + ['type' => 'pointerUp', "button" => 0], + ['type' => 'pause', 'duration' => 10], + ['type' => 'pointerDown', "button" => 0], + ['type' => 'pointerUp', "button" => 0], + ], + ], + ], + ); + $this->getWebDriverSession()->postActions($actions); + $this->getWebDriverSession()->deleteActions(); + } + else { + $this->mouseOver($xpath); + $this->getWebDriverSession()->doubleclick(); + } } public function rightClick(string $xpath) { - $this->mouseOver($xpath); - $this->getWebDriverSession()->click(array('button' => 2)); + if ($this->isW3C()) { + $actions = array( + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse1', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [ + ['type' => 'pointerMove', 'duration' => 0, 'origin' => [Element::WEB_ELEMENT_ID => $this->findElement($xpath)->getID()], 'x' => 0, 'y' => 0], + ['type' => 'pointerDown', "button" => 2], + ['type' => 'pause', 'duration' => 500], + ['type' => 'pointerUp', "button" => 2], + ], + ], + ], + ); + $this->getWebDriverSession()->postActions($actions); + $this->getWebDriverSession()->deleteActions(); + } + else { + $this->mouseOver($xpath); + $this->getWebDriverSession()->click(array('button' => 2)); + } } public function attachFile(string $xpath, string $path) @@ -800,7 +928,12 @@ public function attachFile(string $xpath, string $path) $remotePath = $path; } - $element->postValue(array('value' => array($remotePath))); + if ($this->isW3C()) { + $element->postValue(array('text' => $remotePath)); + } + else { + $element->postValue(array('value' => array($remotePath))); + } } public function isVisible(string $xpath) @@ -810,9 +943,27 @@ public function isVisible(string $xpath) public function mouseOver(string $xpath) { - $this->getWebDriverSession()->moveto(array( - 'element' => $this->findElement($xpath)->getID() - )); + if ($this->isW3C()) { + $actions = array( + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse1', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [ + ['type' => 'pointerMove', 'duration' => 0, 'origin' => [Element::WEB_ELEMENT_ID => $this->findElement($xpath)->getID()], 'x' => 0, 'y' => 0], + ], + ], + ], + ); + $this->getWebDriverSession()->postActions($actions); + $this->getWebDriverSession()->deleteActions(); + } + else { + $this->getWebDriverSession()->moveto(array( + 'element' => $this->findElement($xpath)->getID() + )); + } } public function focus(string $xpath) @@ -848,11 +999,31 @@ public function dragTo(string $sourceXpath, string $destinationXpath) $source = $this->findElement($sourceXpath); $destination = $this->findElement($destinationXpath); - $this->getWebDriverSession()->moveto(array( - 'element' => $source->getID() - )); + if ($this->isW3C()) { + $actions = array( + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse1', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [ + ['type' => 'pointerMove', 'duration' => 0, 'origin' => [Element::WEB_ELEMENT_ID => $this->findElement($sourceXpath)->getID()], 'x' => 0, 'y' => 0], + ['type' => 'pointerDown', "button" => 0], + ['type' => 'pointerMove', 'duration' => 0, 'origin' => [Element::WEB_ELEMENT_ID => $this->findElement($destinationXpath)->getID()], 'x' => 0, 'y' => 0], + ['type' => 'pointerUp', "button" => 0], + ], + ], + ], + ); + $this->getWebDriverSession()->postActions($actions); + $this->getWebDriverSession()->deleteActions(); + } + else { + $this->getWebDriverSession()->moveto(array( + 'element' => $source->getID() + )); - $script = <<withSyn()->executeJsOnElement($source, $script); + $this->withSyn()->executeJsOnElement($source, $script); - $this->getWebDriverSession()->buttondown(); - $this->getWebDriverSession()->moveto(array( - 'element' => $destination->getID() - )); - $this->getWebDriverSession()->buttonup(); + $this->getWebDriverSession()->buttondown(); + $this->getWebDriverSession()->moveto(array( + 'element' => $destination->getID() + )); + $this->getWebDriverSession()->buttonup(); - $script = <<withSyn()->executeJsOnElement($destination, $script); + $this->withSyn()->executeJsOnElement($destination, $script); + } } public function executeScript(string $script) @@ -921,16 +1093,33 @@ public function wait(int $timeout, string $condition) public function resizeWindow(int $width, int $height, ?string $name = null) { - $window = $this->getWebDriverSession()->window($name ?: 'current'); - \assert($window instanceof Window); - $window->postSize( - array('width' => $width, 'height' => $height) - ); + if ($this->isW3C()) { + $this->getWebDriverSession()->window($name ? $name : 'current')->postRect( + array('width' => $width, 'height' => $height) + ); + } + else { + $window = $this->getWebDriverSession()->window($name ?: 'current'); + \assert($window instanceof Window); + $window->postSize( + array('width' => $width, 'height' => $height) + ); + } } public function submitForm(string $xpath) { - $this->findElement($xpath)->submit(); + if ($this->isW3C()) { + $script = <<executeJsOnElement($this->findElement($xpath), $script); + } + else { + $this->findElement($xpath)->submit(); + } } public function maximizeWindow(?string $name = null) diff --git a/tests/Custom/TimeoutTest.php b/tests/Custom/TimeoutTest.php index e17e5f0d..c1d9a667 100644 --- a/tests/Custom/TimeoutTest.php +++ b/tests/Custom/TimeoutTest.php @@ -36,8 +36,17 @@ public function testInvalidTimeoutSettingThrowsException() $driver = $session->getDriver(); \assert($driver instanceof Selenium2Driver); - $this->expectException('\Behat\Mink\Exception\DriverException'); - $driver->setTimeouts(array('invalid' => 0)); + if (method_exists($driver, 'isW3C') && $driver->isW3C()) { + $this->expectException('\WebDriver\Exception\InvalidArgument'); + // WebDriver happily ignores invalid timeout keys, but will + // throw an exception for incorrect values. + $driver->setTimeouts(array('script' => -1)); + } + else { + $this->expectException('\Behat\Mink\Exception\DriverException'); + $driver->setTimeouts(array('invalid' => 0)); + + } } public function testShortTimeoutDoesNotWaitForElementToAppear() diff --git a/tests/Selenium2Config.php b/tests/Selenium2Config.php index faa6cf96..109ebc58 100644 --- a/tests/Selenium2Config.php +++ b/tests/Selenium2Config.php @@ -18,7 +18,7 @@ public static function getInstance(): self public function createDriver(): DriverInterface { $browser = getenv('WEB_FIXTURES_BROWSER') ?: 'firefox'; - $seleniumHost = $_SERVER['DRIVER_URL']; + $seleniumHost = !empty(getenv('DRIVER_URL')) ? getenv('DRIVER_URL') : 'http://localhost:4444/wd/hub'; return new Selenium2Driver($browser, null, $seleniumHost); }