Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

container:
image: drevops/ci-runner:26.6.0@sha256:190027056cac7b7ce28c29a1e5494779ebadc4ee8e95d468b75bc56e7be5aabd
Expand Down Expand Up @@ -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' }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Process test logs and artifacts
if: always()
run: |
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
12 changes: 12 additions & 0 deletions behat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/AccessibilityTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
12 changes: 12 additions & 0 deletions src/CookieTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
1 change: 1 addition & 0 deletions src/DropzoneTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
15 changes: 13 additions & 2 deletions src/FileDownloadTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
73 changes: 56 additions & 17 deletions src/KeyboardTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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),
]);
}

}
14 changes: 0 additions & 14 deletions src/ResponsiveTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;');
Expand All @@ -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()) {
Expand Down
12 changes: 12 additions & 0 deletions tests/behat/bootstrap/FeatureContextTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
Loading