Skip to content

Commit fd2f627

Browse files
authored
[#205] Added selenium-less headless Chrome driver for JavaScript steps. (#689)
1 parent 3d645bd commit fd2f627

13 files changed

Lines changed: 184 additions & 43 deletions

.github/workflows/test.yml

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
continue-on-error: ${{ vars.CI_LINT_DOCS_IGNORE_FAILURE == '1' }}
8080

8181
test:
82-
name: Test PHP ${{ matrix.php_version }}, Drupal ${{ matrix.drupal_version }}, Deps ${{ matrix.deps }}
82+
name: Test PHP ${{ matrix.php_version }}, Drupal ${{ matrix.drupal_version }}, Deps ${{ matrix.deps }}${{ matrix.driver && format(', Driver {0}', matrix.driver) || '' }}
8383
runs-on: ubuntu-latest
8484

8585
strategy:
@@ -116,6 +116,18 @@ jobs:
116116
- php_version: '8.4'
117117
drupal_version: '11'
118118
deps: 'lowest'
119+
# Selenium-less driver legs: run the same suite through headless
120+
# Chrome over the DevTools Protocol to prove the steps are driver
121+
# portable. The browser driver is Drupal-version independent, so a
122+
# single normal-deps leg per Drupal version is enough.
123+
- php_version: '8.3'
124+
drupal_version: '10'
125+
deps: 'normal'
126+
driver: 'chrome_headless'
127+
- php_version: '8.3'
128+
drupal_version: '11'
129+
deps: 'normal'
130+
driver: 'chrome_headless'
119131

120132
container:
121133
image: drevops/ci-runner:26.6.0@sha256:190027056cac7b7ce28c29a1e5494779ebadc4ee8e95d468b75bc56e7be5aabd
@@ -160,25 +172,40 @@ jobs:
160172
ahoy --version
161173
162174
- name: Run Unit tests with coverage
163-
if: matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'
175+
if: "matrix.driver != 'chrome_headless' && matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'"
164176
run: ahoy test-unit-coverage
165177
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
166178

167179
- name: Run Unit tests
168-
if: "!(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')"
180+
if: "matrix.driver != 'chrome_headless' && !(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')"
169181
run: ahoy test-unit
170182
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
171183

172184
- name: Run BDD tests with coverage
173-
if: matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'
185+
if: "matrix.driver != 'chrome_headless' && matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal'"
174186
run: ahoy test-bdd-coverage
175187
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
176188

177189
- name: Run BDD tests
178-
if: "!(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')"
190+
if: "matrix.driver != 'chrome_headless' && !(matrix.php_version == '8.3' && matrix.drupal_version == '11' && matrix.deps == 'normal')"
179191
run: ahoy test-bdd
180192
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
181193

194+
# Selenium-less leg: the same BDD suite driven through headless Chrome
195+
# over the DevTools Protocol, proving the steps work without Selenium.
196+
# The Drupal 11 leg runs with coverage so the driver-specific branches
197+
# (reachable only through chrome-mink) are measured and merged into the
198+
# Codecov report alongside the Selenium2 coverage.
199+
- name: Run BDD tests with coverage with the chrome_headless driver
200+
if: "matrix.driver == 'chrome_headless' && matrix.drupal_version == '11'"
201+
run: ahoy test-bdd-coverage -- -p chrome_headless
202+
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
203+
204+
- name: Run BDD tests with the chrome_headless driver
205+
if: "matrix.driver == 'chrome_headless' && matrix.drupal_version != '11'"
206+
run: ahoy test-bdd -- -p chrome_headless
207+
continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }}
208+
182209
- name: Process test logs and artifacts
183210
if: always()
184211
run: |
@@ -192,7 +219,7 @@ jobs:
192219
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
193220
if: always()
194221
with:
195-
name: test-artifacts-php${{ matrix.php_version }}-drupal${{ matrix.drupal_version }}-${{ matrix.deps }}
222+
name: test-artifacts-php${{ matrix.php_version }}-drupal${{ matrix.drupal_version }}-${{ matrix.deps }}${{ matrix.driver && format('-{0}', matrix.driver) || '' }}
196223
path: .logs
197224
include-hidden-files: true
198225
retention-days: 30

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,34 @@ class FeatureContext extends DrupalContext {
137137
Ensure that your [`behat.yml`](behat.yml) has all the required extensions
138138
enabled.
139139

140+
### JavaScript drivers
141+
142+
Steps that require a real browser (used by scenarios tagged `@javascript`) are
143+
driver agnostic: they work with a Selenium/WebDriver driver and with
144+
selenium-less drivers that talk to Chrome directly over the Chrome DevTools
145+
Protocol. Both are exercised by this library's own CI.
146+
147+
To run `@javascript` scenarios without a Selenium server, add
148+
[`dmore/behat-chrome-extension`](https://gitlab.com/behat-chrome/behat-chrome-extension)
149+
(which pulls in `dmore/chrome-mink-driver`) and point it at a headless Chrome:
150+
151+
```yaml
152+
default:
153+
extensions:
154+
DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~
155+
Behat\MinkExtension:
156+
browser_name: chrome
157+
javascript_session: chrome
158+
chrome:
159+
api_url: 'http://chrome:9222'
160+
```
161+
162+
Any image that exposes a DevTools endpoint works (for example
163+
[`chromedp/headless-shell`](https://hub.docker.com/r/chromedp/headless-shell)).
164+
For local visual debugging you can override `api_url` at runtime via Behat's
165+
`BEHAT_PARAMS` environment variable - for instance to drive a headed Chrome on
166+
your host - as long as that browser can reach your site's `base_url`.
167+
140168
### Exceptions
141169

142170
This library uses [Mink exception classes](https://mink.behat.org/en/latest/)

behat.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,15 @@ default:
9393
target: '%paths.base%/.logs/coverage/behat/cobertura.xml'
9494
php:
9595
target: '%paths.base%/.logs/coverage/behat/phpcov.php'
96+
97+
# Selenium-less profile: drives headless Chrome directly over the DevTools
98+
# Protocol (no Selenium server). Run with "behat -p chrome_headless". Inherits
99+
# all configuration from the "default" profile and only swaps the JavaScript
100+
# session to the "chrome" driver (the session name registered by ChromeExtension).
101+
chrome_headless:
102+
extensions:
103+
DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~
104+
Drupal\MinkExtension:
105+
javascript_session: chrome
106+
chrome:
107+
api_url: 'http://chrome_headless:9222'

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"php": ">=8.2",
2121
"behat/behat": "^3.14",
2222
"behat/mink": ">=1.11",
23+
"dmore/behat-chrome-extension": "^1.4",
2324
"drupal/drupal-extension": "^6.0",
2425
"lullabot/mink-selenium2-driver": "^1.7.4",
2526
"softcreatr/jsonpath": "^0.10 || ^1.0"

docker-compose.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,22 @@ services:
8585
depends_on:
8686
- cli
8787

88+
chrome_headless:
89+
image: chromedp/headless-shell:150.0.7871.47
90+
shm_size: '1gb'
91+
expose:
92+
- "9222"
93+
depends_on:
94+
- cli
95+
8896
# Helper container to wait for services to become available.
8997
wait_dependencies:
9098
image: drevops/docker-wait-for-dependencies:26.1.0
9199
depends_on:
92100
- cli
93101
- mariadb
94-
command: mariadb:3306
102+
- chrome_headless
103+
command: mariadb:3306 chrome_headless:9222
95104

96105
networks: ### Use external networks locally. Automatically removed in CI.
97106
amazeeio-network: ### Automatically removed in CI.

src/AccessibilityTrait.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,10 @@ protected function accessibilityRunEngine(string $rules): array {
465465
));
466466

467467
$session->wait(30000, 'window.__accessibilityResults !== null');
468-
$results = $session->evaluateScript('return window.__accessibilityResults;');
468+
// Serialize to a JSON string in the browser rather than returning the raw
469+
// object: the result graph is large and nested, and some drivers (e.g.
470+
// chrome-mink) cannot walk every property when marshalling a live object.
471+
$results = json_decode((string) $session->evaluateScript('return JSON.stringify(window.__accessibilityResults);'), TRUE);
469472

470473
if (!is_array($results)) {
471474
throw new \RuntimeException('Accessibility engine did not return results.');

src/CookieTrait.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,18 @@ protected function cookieGetAll(): array {
271271
});
272272
}
273273

274+
// CDP-based drivers like the Chrome (chrome-mink) driver.
275+
elseif (method_exists($driver, 'getCookies')) {
276+
$cookies = [];
277+
foreach ($driver->getCookies() as $cookie) {
278+
$cookies[] = [
279+
'name' => $cookie['name'],
280+
'value' => rawurldecode((string) $cookie['value']),
281+
'secure' => $cookie['secure'],
282+
];
283+
}
284+
}
285+
274286
// BrowserKit-based drivers like GoutteDriver.
275287
elseif (method_exists($driver, 'getClient')) {
276288
/** @var \Symfony\Component\BrowserKit\CookieJar $cookie_jar */

src/DropzoneTrait.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public function dropzoneDropFiles(string $selector, TableNode $paths): void {
8383
var input = document.createElement('input');
8484
input.type = 'file';
8585
input.id = ids[i];
86+
input.name = ids[i];
8687
input.style.position = 'fixed';
8788
input.style.left = '-9999px';
8889
input.style.opacity = '0';

src/FileDownloadTrait.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use Behat\Hook\BeforeScenario;
1313
use Behat\Gherkin\Node\PyStringNode;
1414
use Behat\Gherkin\Node\TableNode;
15-
use Behat\Mink\Driver\Selenium2Driver;
1615
use Behat\Mink\Element\NodeElement;
1716
use Behat\Mink\Exception\ElementNotFoundException;
1817
use Behat\Mink\Exception\ExpectationException;
@@ -87,12 +86,24 @@ public function fileDownloadFrom(string $url): void {
8786

8887
/** @var \Behat\Mink\Driver\CoreDriver $driver */
8988
$driver = $this->getSession()->getDriver();
90-
if ($driver instanceof Selenium2Driver) {
89+
90+
// WebDriver-based drivers like Selenium2Driver.
91+
if (method_exists($driver, 'getWebDriverSession')) {
9192
$cookies = $driver->getWebDriverSession()->getAllCookies();
9293
foreach ($cookies as $cookie) {
9394
$cookie_list[] = $cookie['name'] . '=' . $cookie['value'];
9495
}
9596
}
97+
98+
// CDP-based drivers like the Chrome (chrome-mink) driver.
99+
elseif (method_exists($driver, 'getCookies')) {
100+
// @phpstan-ignore-next-line
101+
foreach ($driver->getCookies() as $cookie) {
102+
$cookie_list[] = $cookie['name'] . '=' . $cookie['value'];
103+
}
104+
}
105+
106+
// BrowserKit-based drivers like GoutteDriver.
96107
else {
97108
/** @var \Behat\Mink\Driver\BrowserKitDriver $driver */
98109
// @phpstan-ignore-next-line

src/KeyboardTrait.php

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace DrevOps\BehatSteps;
66

77
use Behat\Step\When;
8+
use Behat\Mink\Driver\BrowserKitDriver;
89
use Behat\Mink\Driver\Selenium2Driver;
910
use Behat\Mink\Exception\ExpectationException;
1011
use Behat\Mink\Exception\UnsupportedDriverActionException;
@@ -92,8 +93,11 @@ public function keyboardPressKeysOnElement(string $keys, ?string $selector): voi
9293
public function keyboardPressKeyOnElementSingle(string $char, ?string $selector): void {
9394
$driver = $this->getSession()->getDriver();
9495

95-
if (!$driver instanceof Selenium2Driver) {
96-
throw new UnsupportedDriverActionException('Method can be used only with Selenium2 driver', $driver);
96+
// Keyboard interaction needs a JavaScript-capable driver: Selenium2 uses
97+
// the bundled Syn library, chrome-mink uses native DevTools key events.
98+
// BrowserKit-based (non-JavaScript) drivers cannot dispatch key events.
99+
if ($driver instanceof BrowserKitDriver) {
100+
throw new UnsupportedDriverActionException('Keyboard interaction is only supported by JavaScript drivers (Selenium2 or Chrome).', $driver);
97101
}
98102

99103
$keys = [
@@ -227,22 +231,57 @@ function getPathTo(element) {
227231
*/
228232
protected function keyboardTriggerKey(string $xpath, string $key): void {
229233
$driver = $this->getSession()->getDriver();
230-
// @codeCoverageIgnoreStart
231-
if (!$driver instanceof Selenium2Driver) {
232-
throw new UnsupportedDriverActionException('Method can be used only with Selenium2 driver', $driver);
234+
235+
// Selenium2 driver: reuse the bundled Syn library via reflection to inject
236+
// synthetic events and execute JS on the element.
237+
if ($driver instanceof Selenium2Driver) {
238+
$reflector = new \ReflectionClass($driver);
239+
$with_syn_reflection = $reflector->getMethod('withSyn');
240+
$execute_js_on_xpath_reflection = $reflector->getMethod('executeJsOnXpath');
241+
$with_syn_result = $with_syn_reflection->invoke($driver);
242+
243+
$execute_js_on_xpath_reflection->invokeArgs($with_syn_result, [
244+
$xpath,
245+
sprintf("syn.key({{ELEMENT}}, '%s');", $key),
246+
]);
247+
248+
return;
249+
}
250+
251+
// CDP-based drivers like the Chrome (chrome-mink) driver: dispatch native
252+
// DevTools key events. Special keys are sent as a keycode down/up pair so
253+
// their default action (focus move, submit, etc.) fires; printable
254+
// characters are sent as a single character so their text is inserted.
255+
$keycodes = [
256+
"\b" => 8,
257+
"\t" => 9,
258+
"\r" => 13,
259+
'shift' => 16,
260+
'ctrl' => 17,
261+
'alt' => 18,
262+
'pause' => 19,
263+
'break' => 19,
264+
'caps' => 20,
265+
'escape' => 27,
266+
'page-up' => 33,
267+
'page-down' => 34,
268+
'end' => 35,
269+
'home' => 36,
270+
'left' => 37,
271+
'up' => 38,
272+
'right' => 39,
273+
'down' => 40,
274+
'insert' => 45,
275+
'delete' => 46,
276+
];
277+
278+
if (isset($keycodes[$key])) {
279+
$driver->keyDown($xpath, $keycodes[$key]);
280+
$driver->keyUp($xpath, $keycodes[$key]);
281+
}
282+
else {
283+
$driver->keyPress($xpath, $key);
233284
}
234-
// @codeCoverageIgnoreEnd
235-
// Use reflection to re-use Syn library injection and execution of JS on
236-
// element.
237-
$reflector = new \ReflectionClass($driver);
238-
$with_syn_reflection = $reflector->getMethod('withSyn');
239-
$execute_js_on_xpath_reflection = $reflector->getMethod('executeJsOnXpath');
240-
$with_syn_result = $with_syn_reflection->invoke($driver);
241-
242-
$execute_js_on_xpath_reflection->invokeArgs($with_syn_result, [
243-
$xpath,
244-
sprintf("syn.key({{ELEMENT}}, '%s');", $key),
245-
]);
246285
}
247286

248287
}

0 commit comments

Comments
 (0)