Skip to content
Open
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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,73 @@ class MyTest extends PantherTestCase
}
```

### Using with Behat

The same fluent browser API is available inside a Behat scenario via the `Zenstruck\Browser\Bridge\Behat\BrowserExtension` and the `BrowserAwareTrait`:

```php
// features/bootstrap/BrowserContext.php
namespace App\Tests\Behat;

use Behat\Behat\Context\Context;
use Behat\Step\When;
use Behat\Step\Then;
use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware;
use Zenstruck\Browser\Bridge\Behat\Context\BrowserAwareTrait;

final class BrowserContext implements Context, BrowserAware
{
use BrowserAwareTrait;

#[When('I visit :url')]
public function iVisit(string $url): void
{
$this->browser()->visit($url);
}

#[Then('I should see :text')]
public function iShouldSee(string $text): void
{
$this->browser()->assertSee($text);
}
}
```

Register the extension in `behat.dist.php` (Behat 4) or `behat.yml.dist` (Behat 3):

```php
// behat.dist.php (Behat 4)
use Behat\Config\Config;
use Behat\Config\Extension;
use Behat\Config\Profile;
use Behat\Config\Suite;

return (new Config())
->withProfile(
(new Profile('default'))
->withSuite(
(new Suite('default'))
->withPaths(__DIR__.'/features')
->withContexts(App\Tests\Behat\BrowserContext::class),
)
->withExtension(new Extension(Zenstruck\Browser\Bridge\Behat\BrowserExtension::class, [
'kernel_class' => App\Kernel::class,
'env' => 'test',
'debug' => true,
]))
)
;
```

The Behat extension picks its Symfony kernel boot strategy automatically:

- If `friends-of-behat/symfony-extension` is installed and enabled in the same Behat config, the kernel is consumed from that extension (`SymfonyExtensionKernelBooter`).
- Otherwise the kernel is booted directly from the `kernel_class` config key (`StandaloneKernelBooter`), defaulting to the `KERNEL_CLASS` env var when not provided.

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.

> **Note:** Using `PantherBrowser` from Behat additionally requires `symfony/panther`, which itself pulls in `phpunit/phpunit`.

## Configuration

There are several environment variables available to configure:
Expand Down
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"zenstruck/callback": "^1.4.2"
},
"require-dev": {
"behat/behat": "^3.13|^4.0@dev",
"dbrekelmans/bdi": "^1.0",
"justinrainbow/json-schema": "^5.3",
"mtdowling/jmespath.php": "^2.6",
Expand All @@ -34,6 +35,8 @@
"symfony/security-bundle": "^6.4|^7.0|^8.0"
},
"suggest": {
"behat/behat": "To use the Behat bridge under Zenstruck\\Browser\\Bridge\\Behat.",
"friends-of-behat/symfony-extension": "To boot the Symfony kernel under Behat via SymfonyExtensionKernelBooter (otherwise the StandaloneKernelBooter is used).",
"justinrainbow/json-schema": "Json schema validator. Needed to use Json::assertMatchesSchema().",
"mtdowling/jmespath.php": "PHP implementation for JMESPath. Needed to use Json assertions."
},
Expand All @@ -45,7 +48,10 @@
"psr-4": { "Zenstruck\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Zenstruck\\Browser\\Tests\\": "tests/" }
"psr-4": {
"Zenstruck\\Browser\\Tests\\": "tests/",
"Zenstruck\\Browser\\Tests\\Behat\\": "tests/Behat/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
Expand Down
103 changes: 103 additions & 0 deletions src/Browser/Artifact/ArtifactCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/*
* This file is part of the zenstruck/browser package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Browser\Artifact;

use Zenstruck\Browser\BrowserRegistry;

/**
* Drives the test-or-scenario lifecycle that captures browser state on failure
* and accumulates the "Saved Browser Artifacts" summary printed at end of suite.
* Test-runner-agnostic: PHPUnit and Behat each wire their own events to these
* methods.
*
* @author Hugo Hamon <hello@kodero.fr>
*/
final class ArtifactCollector
{
/** @var array<string, array<string, string[]>> */
private array $savedArtifacts = [];

public function __construct(
private readonly BrowserRegistry $registry,
private readonly ArtifactSink $sink,
) {
}

public function onSuiteStart(): void
{
$this->registry->start();
}

public function onScenarioStart(string $name): void
{
$this->registry->clear();
}

public function onScenarioFailed(string $name, FailureType $type): void
{
if ($this->registry->isEmpty()) {
return;
}

$filename = \sprintf('%s_%s', $type->value, self::normalizeName($name));

foreach ($this->registry->all() as $i => $browser) {
try {
$browser->saveCurrentState("{$filename}__{$i}");
} catch (\Throwable) {
// swallow exceptions related to dumping the current state so as to not
// lose the actual error/failure being reported by the test runner
}
}
}

public function onScenarioFinish(string $name): void
{
foreach ($this->registry->all() as $browser) {
foreach ($browser->savedArtifacts() as $category => $artifacts) {
if (\count($artifacts) === 0) {
continue;
}

$this->savedArtifacts[$name][$category] = $artifacts;
}
}

$this->registry->clear();
}

public function onSuiteFinish(): void
{
$this->sink->writeSummary($this->savedArtifacts);
$this->registry->stop();
}

private static function normalizeName(string $name): string
{
if (!\mb_strstr($name, 'with data set')) {
return \strtr($name, '\\:', '-_');
}

// Try to match for a numeric data set index. If it didn't, match for a string one.
if (!\preg_match('#^(?<test>[\w:\\\]+) with data set \#(?<dataset>\d+)#', $name, $matches)) {
\preg_match('#^(?<test>[\w:\\\]+) with data set "(?<dataset>.*)"#', $name, $matches);
}

$normalized = \strtr($matches['test'], '\\:', '-_');

if (isset($matches['dataset'])) {
$normalized .= '__data-set-'.\preg_replace('/\W+/', '-', $matches['dataset']);
}

return $normalized;
}
}
27 changes: 27 additions & 0 deletions src/Browser/Artifact/ArtifactSink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the zenstruck/browser package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Browser\Artifact;

/**
* Destination for the end-of-suite "Saved Browser Artifacts" summary. PHPUnit
* uses {@see EchoArtifactSink}; Behat ships a sink that writes through Behat's
* output printer instead of plain echo.
*
* @author Hugo Hamon <hello@kodero.fr>
*/
interface ArtifactSink
{
/**
* @param array<string, array<string, string[]>> $savedArtifacts indexed by test name then category
*/
public function writeSummary(array $savedArtifacts): void;
}
42 changes: 42 additions & 0 deletions src/Browser/Artifact/EchoArtifactSink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the zenstruck/browser package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Browser\Artifact;

/**
* Preserves the historical PHPUnit-extension output: a plain `echo` of the
* "Saved Browser Artifacts" summary at end of suite.
*
* @author Hugo Hamon <hello@kodero.fr>
*/
final class EchoArtifactSink implements ArtifactSink
{
public function writeSummary(array $savedArtifacts): void
{
if ($savedArtifacts === []) {
return;
}

echo "\n\nSaved Browser Artifacts:";

foreach ($savedArtifacts as $test => $categories) {
echo "\n\n {$test}";

foreach ($categories as $category => $artifacts) {
echo "\n {$category}:";

foreach ($artifacts as $artifact) {
echo "\n * {$artifact}:";
}
}
}
}
}
25 changes: 25 additions & 0 deletions src/Browser/Artifact/FailureType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the zenstruck/browser package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Browser\Artifact;

/**
* Distinguishes the two failure flavors PHPUnit reports: errors (unexpected
* exceptions) and failures (assertion failures). The backed value is used as
* the filename prefix when dumping a browser's state on failure.
*
* @author Hugo Hamon <hello@kodero.fr>
*/
enum FailureType: string
{
case Error = 'error';
case Failure = 'failure';
}
Loading
Loading