Skip to content

Commit bfe4485

Browse files
committed
Add Behat support
- `BrowserOptions` (readonly value object) — env-var-driven configuration - `BrowserRegistry` — replaces the static state previously held by `LegacyExtension` - `KernelBooter` interface + `Test/PhpUnitKernelBooter` (closure-fed, captures the trait's privileged scope) - `BrowserFactory` — builds `KernelBrowser` / `PantherBrowser` from a booter - `Artifact/{ArtifactSink, ArtifactCollector, EchoArtifactSink, FailureType}` — the lifecycle/artifact-capture logic extracted out of `LegacyExtension` - `HasBrowser` trait: now ~95 lines, delegates to `BrowserFactory` with closures granting protected-static access - `LegacyExtension` / `BootstrappedExtension`: thin adapters forwarding events to `ArtifactCollector` - `BrowserExtension`: unchanged externally — same `phpunit.xml.dist` snippet works - `BrowserExtension` — Behat `ServiceContainer` Extension wiring everything - `Context/{BrowserAware, BrowserAwareTrait}` — drop-in analog of `HasBrowser` - `Initializer/BrowserContextInitializer` — injects services via Behat's standard DI - `EventListener/ArtifactListener` — Suite + Scenario events drive `ArtifactCollector` - `Output/BehatOutputArtifactSink` — STDOUT-direct summary writer - `Kernel/{KernelBooter implementations}` — `StandaloneKernelBooter` (env-var) and `SymfonyExtensionKernelBooter` (FoB), selected at compile time via class_exists; PantherClientFactory adapter to bypass Panther's protected statics - `composer.json`: `behat/behat ^3.13|^4.0@dev` added to `require-dev`; both behat and FoB extension listed in suggest - `tests/Behat/{behat.dist.php, behat.yml.dist, Context/BrowserContext.php, features/browser.feature}` — exercises the bridge end-to-end against the existing fixture kernel - `README.md`: new "Using with Behat" section with context + config examples Known caveat documented in the plan: `friends-of-behat/symfony-extension` doesn't yet support Symfony 8, so I could only end-to-end verify the `StandaloneKernelBooter` path. The `SymfonyExtensionKernelBooter` is code-complete and PHPStan-clean, but will need either a downgraded composer profile or a FoB release that supports Symfony 8 to verify in CI. The class is wired conditionally so it doesn't break consumers without FoB installed.
1 parent 741153a commit bfe4485

36 files changed

Lines changed: 3004 additions & 148 deletions

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,73 @@ class MyTest extends PantherTestCase
547547
}
548548
```
549549

550+
### Using with Behat
551+
552+
The same fluent browser API is available inside a Behat scenario via the `Zenstruck\Browser\Bridge\Behat\BrowserExtension` and the `BrowserAwareTrait`:
553+
554+
```php
555+
// features/bootstrap/BrowserContext.php
556+
namespace App\Tests\Behat;
557+
558+
use Behat\Behat\Context\Context;
559+
use Behat\Step\When;
560+
use Behat\Step\Then;
561+
use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware;
562+
use Zenstruck\Browser\Bridge\Behat\Context\BrowserAwareTrait;
563+
564+
final class BrowserContext implements Context, BrowserAware
565+
{
566+
use BrowserAwareTrait;
567+
568+
#[When('I visit :url')]
569+
public function iVisit(string $url): void
570+
{
571+
$this->browser()->visit($url);
572+
}
573+
574+
#[Then('I should see :text')]
575+
public function iShouldSee(string $text): void
576+
{
577+
$this->browser()->assertSee($text);
578+
}
579+
}
580+
```
581+
582+
Register the extension in `behat.dist.php` (Behat 4) or `behat.yml.dist` (Behat 3):
583+
584+
```php
585+
// behat.dist.php (Behat 4)
586+
use Behat\Config\Config;
587+
use Behat\Config\Extension;
588+
use Behat\Config\Profile;
589+
use Behat\Config\Suite;
590+
591+
return (new Config())
592+
->withProfile(
593+
(new Profile('default'))
594+
->withSuite(
595+
(new Suite('default'))
596+
->withPaths(__DIR__.'/features')
597+
->withContexts(App\Tests\Behat\BrowserContext::class),
598+
)
599+
->withExtension(new Extension(Zenstruck\Browser\Bridge\Behat\BrowserExtension::class, [
600+
'kernel_class' => App\Kernel::class,
601+
'env' => 'test',
602+
'debug' => true,
603+
]))
604+
)
605+
;
606+
```
607+
608+
The Behat extension picks its Symfony kernel boot strategy automatically:
609+
610+
- If `friends-of-behat/symfony-extension` is installed and enabled in the same Behat config, the kernel is consumed from that extension (`SymfonyExtensionKernelBooter`).
611+
- Otherwise the kernel is booted directly from the `kernel_class` config key (`StandaloneKernelBooter`), defaulting to the `KERNEL_CLASS` env var when not provided.
612+
613+
Each scenario starts with a fresh kernel (reboot between scenarios), and the same artifact-capture-on-failure behavior (screenshots, HTML dumps) wired into PHPUnit is also wired into the Behat scenario lifecycle.
614+
615+
> **Note:** Using `PantherBrowser` from Behat additionally requires `symfony/panther`, which itself pulls in `phpunit/phpunit`.
616+
550617
## Configuration
551618

552619
There are several environment variables available to configure:

composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"zenstruck/callback": "^1.4.2"
2323
},
2424
"require-dev": {
25+
"behat/behat": "^3.13|^4.0@dev",
2526
"dbrekelmans/bdi": "^1.0",
2627
"justinrainbow/json-schema": "^5.3",
2728
"mtdowling/jmespath.php": "^2.6",
@@ -34,6 +35,8 @@
3435
"symfony/security-bundle": "^6.4|^7.0|^8.0"
3536
},
3637
"suggest": {
38+
"behat/behat": "To use the Behat bridge under Zenstruck\\Browser\\Bridge\\Behat.",
39+
"friends-of-behat/symfony-extension": "To boot the Symfony kernel under Behat via SymfonyExtensionKernelBooter (otherwise the StandaloneKernelBooter is used).",
3740
"justinrainbow/json-schema": "Json schema validator. Needed to use Json::assertMatchesSchema().",
3841
"mtdowling/jmespath.php": "PHP implementation for JMESPath. Needed to use Json assertions."
3942
},
@@ -45,7 +48,10 @@
4548
"psr-4": { "Zenstruck\\": "src/" }
4649
},
4750
"autoload-dev": {
48-
"psr-4": { "Zenstruck\\Browser\\Tests\\": "tests/" }
51+
"psr-4": {
52+
"Zenstruck\\Browser\\Tests\\": "tests/",
53+
"Zenstruck\\Browser\\Tests\\Behat\\": "tests/Behat/"
54+
}
4955
},
5056
"minimum-stability": "dev",
5157
"prefer-stable": true
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/browser package.
5+
*
6+
* (c) Kevin Bond <kevinbond@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Browser\Artifact;
13+
14+
use Zenstruck\Browser\BrowserRegistry;
15+
16+
/**
17+
* Drives the test-or-scenario lifecycle that captures browser state on failure
18+
* and accumulates the "Saved Browser Artifacts" summary printed at end of suite.
19+
* Test-runner-agnostic: PHPUnit and Behat each wire their own events to these
20+
* methods.
21+
*
22+
* @author Hugo Hamon <hello@kodero.fr>
23+
*/
24+
final class ArtifactCollector
25+
{
26+
/** @var array<string, array<string, string[]>> */
27+
private array $savedArtifacts = [];
28+
29+
public function __construct(
30+
private readonly BrowserRegistry $registry,
31+
private readonly ArtifactSink $sink,
32+
) {
33+
}
34+
35+
public function onSuiteStart(): void
36+
{
37+
$this->registry->start();
38+
}
39+
40+
public function onScenarioStart(string $name): void
41+
{
42+
$this->registry->clear();
43+
}
44+
45+
public function onScenarioFailed(string $name, FailureType $type): void
46+
{
47+
if ($this->registry->isEmpty()) {
48+
return;
49+
}
50+
51+
$filename = \sprintf('%s_%s', $type->value, self::normalizeName($name));
52+
53+
foreach ($this->registry->all() as $i => $browser) {
54+
try {
55+
$browser->saveCurrentState("{$filename}__{$i}");
56+
} catch (\Throwable) {
57+
// swallow exceptions related to dumping the current state so as to not
58+
// lose the actual error/failure being reported by the test runner
59+
}
60+
}
61+
}
62+
63+
public function onScenarioFinish(string $name): void
64+
{
65+
foreach ($this->registry->all() as $browser) {
66+
foreach ($browser->savedArtifacts() as $category => $artifacts) {
67+
if (\count($artifacts) === 0) {
68+
continue;
69+
}
70+
71+
$this->savedArtifacts[$name][$category] = $artifacts;
72+
}
73+
}
74+
75+
$this->registry->clear();
76+
}
77+
78+
public function onSuiteFinish(): void
79+
{
80+
$this->sink->writeSummary($this->savedArtifacts);
81+
$this->registry->stop();
82+
}
83+
84+
private static function normalizeName(string $name): string
85+
{
86+
if (!\mb_strstr($name, 'with data set')) {
87+
return \strtr($name, '\\:', '-_');
88+
}
89+
90+
// Try to match for a numeric data set index. If it didn't, match for a string one.
91+
if (!\preg_match('#^(?<test>[\w:\\\]+) with data set \#(?<dataset>\d+)#', $name, $matches)) {
92+
\preg_match('#^(?<test>[\w:\\\]+) with data set "(?<dataset>.*)"#', $name, $matches);
93+
}
94+
95+
$normalized = \strtr($matches['test'], '\\:', '-_');
96+
97+
if (isset($matches['dataset'])) {
98+
$normalized .= '__data-set-'.\preg_replace('/\W+/', '-', $matches['dataset']);
99+
}
100+
101+
return $normalized;
102+
}
103+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/browser package.
5+
*
6+
* (c) Kevin Bond <kevinbond@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Browser\Artifact;
13+
14+
/**
15+
* Destination for the end-of-suite "Saved Browser Artifacts" summary. PHPUnit
16+
* uses {@see EchoArtifactSink}; Behat ships a sink that writes through Behat's
17+
* output printer instead of plain echo.
18+
*
19+
* @author Hugo Hamon <hello@kodero.fr>
20+
*/
21+
interface ArtifactSink
22+
{
23+
/**
24+
* @param array<string, array<string, string[]>> $savedArtifacts indexed by test name then category
25+
*/
26+
public function writeSummary(array $savedArtifacts): void;
27+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/browser package.
5+
*
6+
* (c) Kevin Bond <kevinbond@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Browser\Artifact;
13+
14+
/**
15+
* Preserves the historical PHPUnit-extension output: a plain `echo` of the
16+
* "Saved Browser Artifacts" summary at end of suite.
17+
*
18+
* @author Hugo Hamon <hello@kodero.fr>
19+
*/
20+
final class EchoArtifactSink implements ArtifactSink
21+
{
22+
public function writeSummary(array $savedArtifacts): void
23+
{
24+
if ($savedArtifacts === []) {
25+
return;
26+
}
27+
28+
echo "\n\nSaved Browser Artifacts:";
29+
30+
foreach ($savedArtifacts as $test => $categories) {
31+
echo "\n\n {$test}";
32+
33+
foreach ($categories as $category => $artifacts) {
34+
echo "\n {$category}:";
35+
36+
foreach ($artifacts as $artifact) {
37+
echo "\n * {$artifact}:";
38+
}
39+
}
40+
}
41+
}
42+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/browser package.
5+
*
6+
* (c) Kevin Bond <kevinbond@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Browser\Artifact;
13+
14+
/**
15+
* Distinguishes the two failure flavors PHPUnit reports: errors (unexpected
16+
* exceptions) and failures (assertion failures). The backed value is used as
17+
* the filename prefix when dumping a browser's state on failure.
18+
*
19+
* @author Hugo Hamon <hello@kodero.fr>
20+
*/
21+
enum FailureType: string
22+
{
23+
case Error = 'error';
24+
case Failure = 'failure';
25+
}

0 commit comments

Comments
 (0)