diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff10acaf..ff59378e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: continue-on-error: ${{ vars.CI_LINT_DOCS_IGNORE_FAILURE == '1' }} test: - name: Test PHP ${{ matrix.php_version }}, Drupal ${{ matrix.drupal_version }}, Deps ${{ matrix.deps }} + name: Test PHP ${{ matrix.php_version }}, Drupal ${{ matrix.drupal_version }}, Deps ${{ matrix.deps }}${{ matrix.driver && format(', Driver {0}', matrix.driver) || '' }} runs-on: ubuntu-latest strategy: @@ -116,6 +116,18 @@ jobs: - php_version: '8.4' drupal_version: '11' deps: 'lowest' + # Selenium-less driver legs: run the same suite through headless + # Chrome over the DevTools Protocol to prove the steps are driver + # portable. The browser driver is Drupal-version independent, so a + # single normal-deps leg per Drupal version is enough. + - php_version: '8.3' + drupal_version: '10' + deps: 'normal' + driver: 'chrome_headless' + - php_version: '8.3' + drupal_version: '11' + deps: 'normal' + driver: 'chrome_headless' container: image: drevops/ci-runner:26.6.0@sha256:190027056cac7b7ce28c29a1e5494779ebadc4ee8e95d468b75bc56e7be5aabd @@ -160,25 +172,40 @@ jobs: ahoy --version - name: Run Unit tests with coverage - if: matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal' + if: "matrix.driver != 'chrome_headless' && matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'" run: ahoy test-unit-coverage continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} - name: Run Unit tests - if: "!(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')" + if: "matrix.driver != 'chrome_headless' && !(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')" run: ahoy test-unit continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} - name: Run BDD tests with coverage - if: matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal' + if: "matrix.driver != 'chrome_headless' && matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'" run: ahoy test-bdd-coverage continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} - name: Run BDD tests - if: "!(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')" + if: "matrix.driver != 'chrome_headless' && !(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')" run: ahoy test-bdd continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} + # Selenium-less leg: the same BDD suite driven through headless Chrome + # over the DevTools Protocol, proving the steps work without Selenium. + # The Drupal 11 leg runs with coverage so the driver-specific branches + # (reachable only through chrome-mink) are measured and merged into the + # Codecov report alongside the Selenium2 coverage. + - name: Run BDD tests with coverage with the chrome_headless driver + if: "matrix.driver == 'chrome_headless' && matrix.drupal_version == '11'" + run: ahoy test-bdd-coverage -- -p chrome_headless + continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} + + - name: Run BDD tests with the chrome_headless driver + if: "matrix.driver == 'chrome_headless' && matrix.drupal_version != '11'" + run: ahoy test-bdd -- -p chrome_headless + continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} + - name: Process test logs and artifacts if: always() run: | @@ -192,7 +219,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: test-artifacts-php${{ matrix.php_version }}-drupal${{ matrix.drupal_version }}-${{ matrix.deps }} + name: test-artifacts-php${{ matrix.php_version }}-drupal${{ matrix.drupal_version }}-${{ matrix.deps }}${{ matrix.driver && format('-{0}', matrix.driver) || '' }} path: .logs include-hidden-files: true retention-days: 30 diff --git a/README.md b/README.md index 7b7f1519..cc48b548 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,34 @@ class FeatureContext extends DrupalContext { Ensure that your [`behat.yml`](behat.yml) has all the required extensions enabled. +### JavaScript drivers + +Steps that require a real browser (used by scenarios tagged `@javascript`) are +driver agnostic: they work with a Selenium/WebDriver driver and with +selenium-less drivers that talk to Chrome directly over the Chrome DevTools +Protocol. Both are exercised by this library's own CI. + +To run `@javascript` scenarios without a Selenium server, add +[`dmore/behat-chrome-extension`](https://gitlab.com/behat-chrome/behat-chrome-extension) +(which pulls in `dmore/chrome-mink-driver`) and point it at a headless Chrome: + +```yaml +default: + extensions: + DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~ + Behat\MinkExtension: + browser_name: chrome + javascript_session: chrome + chrome: + api_url: 'http://chrome:9222' +``` + +Any image that exposes a DevTools endpoint works (for example +[`chromedp/headless-shell`](https://hub.docker.com/r/chromedp/headless-shell)). +For local visual debugging you can override `api_url` at runtime via Behat's +`BEHAT_PARAMS` environment variable - for instance to drive a headed Chrome on +your host - as long as that browser can reach your site's `base_url`. + ### Exceptions This library uses [Mink exception classes](https://mink.behat.org/en/latest/) diff --git a/behat.yml b/behat.yml index 1ca05fd7..8b2dafa4 100644 --- a/behat.yml +++ b/behat.yml @@ -93,3 +93,15 @@ default: target: '%paths.base%/.logs/coverage/behat/cobertura.xml' php: target: '%paths.base%/.logs/coverage/behat/phpcov.php' + +# Selenium-less profile: drives headless Chrome directly over the DevTools +# Protocol (no Selenium server). Run with "behat -p chrome_headless". Inherits +# all configuration from the "default" profile and only swaps the JavaScript +# session to the "chrome" driver (the session name registered by ChromeExtension). +chrome_headless: + extensions: + DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~ + Drupal\MinkExtension: + javascript_session: chrome + chrome: + api_url: 'http://chrome_headless:9222' diff --git a/composer.json b/composer.json index 19b241ff..318816a6 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "php": ">=8.2", "behat/behat": "^3.14", "behat/mink": ">=1.11", + "dmore/behat-chrome-extension": "^1.4", "drupal/drupal-extension": "^6.0", "lullabot/mink-selenium2-driver": "^1.7.4", "softcreatr/jsonpath": "^0.10 || ^1.0" diff --git a/docker-compose.yml b/docker-compose.yml index 691857af..024b4e01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,13 +85,22 @@ services: depends_on: - cli + chrome_headless: + image: chromedp/headless-shell:150.0.7871.47 + shm_size: '1gb' + expose: + - "9222" + depends_on: + - cli + # Helper container to wait for services to become available. wait_dependencies: image: drevops/docker-wait-for-dependencies:26.1.0 depends_on: - cli - mariadb - command: mariadb:3306 + - chrome_headless + command: mariadb:3306 chrome_headless:9222 networks: ### Use external networks locally. Automatically removed in CI. amazeeio-network: ### Automatically removed in CI. diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index 6c89d76d..3289c311 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -465,7 +465,10 @@ protected function accessibilityRunEngine(string $rules): array { )); $session->wait(30000, 'window.__accessibilityResults !== null'); - $results = $session->evaluateScript('return window.__accessibilityResults;'); + // Serialize to a JSON string in the browser rather than returning the raw + // object: the result graph is large and nested, and some drivers (e.g. + // chrome-mink) cannot walk every property when marshalling a live object. + $results = json_decode((string) $session->evaluateScript('return JSON.stringify(window.__accessibilityResults);'), TRUE); if (!is_array($results)) { throw new \RuntimeException('Accessibility engine did not return results.'); diff --git a/src/CookieTrait.php b/src/CookieTrait.php index ef611e2e..c4fb603a 100644 --- a/src/CookieTrait.php +++ b/src/CookieTrait.php @@ -271,6 +271,18 @@ protected function cookieGetAll(): array { }); } + // CDP-based drivers like the Chrome (chrome-mink) driver. + elseif (method_exists($driver, 'getCookies')) { + $cookies = []; + foreach ($driver->getCookies() as $cookie) { + $cookies[] = [ + 'name' => $cookie['name'], + 'value' => rawurldecode((string) $cookie['value']), + 'secure' => $cookie['secure'], + ]; + } + } + // BrowserKit-based drivers like GoutteDriver. elseif (method_exists($driver, 'getClient')) { /** @var \Symfony\Component\BrowserKit\CookieJar $cookie_jar */ diff --git a/src/DropzoneTrait.php b/src/DropzoneTrait.php index 462838ba..7df33102 100644 --- a/src/DropzoneTrait.php +++ b/src/DropzoneTrait.php @@ -83,6 +83,7 @@ public function dropzoneDropFiles(string $selector, TableNode $paths): void { var input = document.createElement('input'); input.type = 'file'; input.id = ids[i]; + input.name = ids[i]; input.style.position = 'fixed'; input.style.left = '-9999px'; input.style.opacity = '0'; diff --git a/src/FileDownloadTrait.php b/src/FileDownloadTrait.php index 11ce9c72..fc55e8d6 100644 --- a/src/FileDownloadTrait.php +++ b/src/FileDownloadTrait.php @@ -12,7 +12,6 @@ use Behat\Hook\BeforeScenario; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; -use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Element\NodeElement; use Behat\Mink\Exception\ElementNotFoundException; use Behat\Mink\Exception\ExpectationException; @@ -87,12 +86,24 @@ public function fileDownloadFrom(string $url): void { /** @var \Behat\Mink\Driver\CoreDriver $driver */ $driver = $this->getSession()->getDriver(); - if ($driver instanceof Selenium2Driver) { + + // WebDriver-based drivers like Selenium2Driver. + if (method_exists($driver, 'getWebDriverSession')) { $cookies = $driver->getWebDriverSession()->getAllCookies(); foreach ($cookies as $cookie) { $cookie_list[] = $cookie['name'] . '=' . $cookie['value']; } } + + // CDP-based drivers like the Chrome (chrome-mink) driver. + elseif (method_exists($driver, 'getCookies')) { + // @phpstan-ignore-next-line + foreach ($driver->getCookies() as $cookie) { + $cookie_list[] = $cookie['name'] . '=' . $cookie['value']; + } + } + + // BrowserKit-based drivers like GoutteDriver. else { /** @var \Behat\Mink\Driver\BrowserKitDriver $driver */ // @phpstan-ignore-next-line diff --git a/src/KeyboardTrait.php b/src/KeyboardTrait.php index fd743e39..9aef0e3f 100644 --- a/src/KeyboardTrait.php +++ b/src/KeyboardTrait.php @@ -5,6 +5,7 @@ namespace DrevOps\BehatSteps; use Behat\Step\When; +use Behat\Mink\Driver\BrowserKitDriver; use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Exception\ExpectationException; use Behat\Mink\Exception\UnsupportedDriverActionException; @@ -92,8 +93,11 @@ public function keyboardPressKeysOnElement(string $keys, ?string $selector): voi public function keyboardPressKeyOnElementSingle(string $char, ?string $selector): void { $driver = $this->getSession()->getDriver(); - if (!$driver instanceof Selenium2Driver) { - throw new UnsupportedDriverActionException('Method can be used only with Selenium2 driver', $driver); + // Keyboard interaction needs a JavaScript-capable driver: Selenium2 uses + // the bundled Syn library, chrome-mink uses native DevTools key events. + // BrowserKit-based (non-JavaScript) drivers cannot dispatch key events. + if ($driver instanceof BrowserKitDriver) { + throw new UnsupportedDriverActionException('Keyboard interaction is only supported by JavaScript drivers (Selenium2 or Chrome).', $driver); } $keys = [ @@ -227,22 +231,57 @@ function getPathTo(element) { */ protected function keyboardTriggerKey(string $xpath, string $key): void { $driver = $this->getSession()->getDriver(); - // @codeCoverageIgnoreStart - if (!$driver instanceof Selenium2Driver) { - throw new UnsupportedDriverActionException('Method can be used only with Selenium2 driver', $driver); + + // Selenium2 driver: reuse the bundled Syn library via reflection to inject + // synthetic events and execute JS on the element. + if ($driver instanceof Selenium2Driver) { + $reflector = new \ReflectionClass($driver); + $with_syn_reflection = $reflector->getMethod('withSyn'); + $execute_js_on_xpath_reflection = $reflector->getMethod('executeJsOnXpath'); + $with_syn_result = $with_syn_reflection->invoke($driver); + + $execute_js_on_xpath_reflection->invokeArgs($with_syn_result, [ + $xpath, + sprintf("syn.key({{ELEMENT}}, '%s');", $key), + ]); + + return; + } + + // CDP-based drivers like the Chrome (chrome-mink) driver: dispatch native + // DevTools key events. Special keys are sent as a keycode down/up pair so + // their default action (focus move, submit, etc.) fires; printable + // characters are sent as a single character so their text is inserted. + $keycodes = [ + "\b" => 8, + "\t" => 9, + "\r" => 13, + 'shift' => 16, + 'ctrl' => 17, + 'alt' => 18, + 'pause' => 19, + 'break' => 19, + 'caps' => 20, + 'escape' => 27, + 'page-up' => 33, + 'page-down' => 34, + 'end' => 35, + 'home' => 36, + 'left' => 37, + 'up' => 38, + 'right' => 39, + 'down' => 40, + 'insert' => 45, + 'delete' => 46, + ]; + + if (isset($keycodes[$key])) { + $driver->keyDown($xpath, $keycodes[$key]); + $driver->keyUp($xpath, $keycodes[$key]); + } + else { + $driver->keyPress($xpath, $key); } - // @codeCoverageIgnoreEnd - // Use reflection to re-use Syn library injection and execution of JS on - // element. - $reflector = new \ReflectionClass($driver); - $with_syn_reflection = $reflector->getMethod('withSyn'); - $execute_js_on_xpath_reflection = $reflector->getMethod('executeJsOnXpath'); - $with_syn_result = $with_syn_reflection->invoke($driver); - - $execute_js_on_xpath_reflection->invokeArgs($with_syn_result, [ - $xpath, - sprintf("syn.key({{ELEMENT}}, '%s');", $key), - ]); } } diff --git a/src/ResponsiveTrait.php b/src/ResponsiveTrait.php index 9c2e8e51..9681261b 100644 --- a/src/ResponsiveTrait.php +++ b/src/ResponsiveTrait.php @@ -11,7 +11,6 @@ use Behat\Gherkin\Node\TableNode; use Behat\Hook\BeforeScenario; use Behat\Hook\BeforeStep; -use Behat\Mink\Driver\Selenium2Driver; /** * Test responsive layouts with viewport control. @@ -340,17 +339,10 @@ protected function responsiveExtractDimensions(string $dimensions, ?string $name * Array with 'width' and 'height' keys. */ protected function responsiveGetCurrentDimensions(): array { - $driver = $this->getSession()->getDriver(); - // Default dimensions if unable to determine current size. $default_width = 1280; $default_height = 800; - // @codeCoverageIgnoreStart - if (!$driver instanceof Selenium2Driver) { - return ['width' => $default_width, 'height' => $default_height]; - } - // @codeCoverageIgnoreEnd try { $width = $this->getSession()->evaluateScript('return window.innerWidth;'); $height = $this->getSession()->evaluateScript('return window.innerHeight;'); @@ -376,12 +368,6 @@ protected function responsiveGetCurrentDimensions(): array { * The height in pixels. */ protected function responsiveResize(int $width, int $height): void { - $driver = $this->getSession()->getDriver(); - - if (!$driver instanceof Selenium2Driver) { - return; - } - try { // Ensure session is started before resizing. if (!$this->getSession()->isStarted()) { diff --git a/tests/behat/bootstrap/FeatureContextTrait.php b/tests/behat/bootstrap/FeatureContextTrait.php index a1587fad..5e6588bb 100644 --- a/tests/behat/bootstrap/FeatureContextTrait.php +++ b/tests/behat/bootstrap/FeatureContextTrait.php @@ -372,6 +372,13 @@ protected function testGetAllCookies(): array { $cookie_list[$cookie['name']] = $cookie['value']; } } + + // CDP-based drivers like the Chrome (chrome-mink) driver. + elseif (method_exists($driver, 'getCookies')) { + foreach ($driver->getCookies() as $cookie) { + $cookie_list[$cookie['name']] = rawurldecode((string) $cookie['value']); + } + } else { /** @var \Behat\Mink\Driver\BrowserKitDriver $driver */ // @phpstan-ignore-next-line @@ -405,6 +412,11 @@ public function testSetCookie(string $name, string $value): void { $cookie = new Cookie($name, rawurlencode($value)); $cookie_jar->set($cookie); } + + // CDP-based drivers like the Chrome (chrome-mink) driver. + if (method_exists($driver, 'getCookies')) { + $driver->setCookie($name, $value); + } } /** diff --git a/tests/behat/features/keyboard.feature b/tests/behat/features/keyboard.feature index 1efd05f8..5046438e 100644 --- a/tests/behat/features/keyboard.feature +++ b/tests/behat/features/keyboard.feature @@ -25,7 +25,7 @@ Feature: Check that KeyboardTrait works Then the "input1" field should contain "hello" @trait:KeyboardTrait - Scenario: Assert that negative assertion for "When I press the keys :keys on the element :selector" step throws an exception for using with a driver other than Selenium2Driver + Scenario: Assert that negative assertion for "When I press the keys :keys on the element :selector" step throws an exception for using with a non-JavaScript driver Given some behat configuration And scenario steps: """ @@ -36,7 +36,7 @@ Feature: Check that KeyboardTrait works When I run "behat --no-colors" Then it should fail with a "Behat\Mink\Exception\UnsupportedDriverActionException" exception: """ - Method can be used only with Selenium2 driver + Keyboard interaction is only supported by JavaScript drivers (Selenium2 or Chrome). """ @api @javascript