diff --git a/README.md b/README.md index 6b80288..26fe4d1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/composer.json b/composer.json index a75f16a..f42c0d3 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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." }, @@ -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 diff --git a/src/Browser/Artifact/ArtifactCollector.php b/src/Browser/Artifact/ArtifactCollector.php new file mode 100644 index 0000000..5eb6dab --- /dev/null +++ b/src/Browser/Artifact/ArtifactCollector.php @@ -0,0 +1,103 @@ + + * + * 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 + */ +final class ArtifactCollector +{ + /** @var array> */ + 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('#^(?[\w:\\\]+) with data set \#(?\d+)#', $name, $matches)) { + \preg_match('#^(?[\w:\\\]+) with data set "(?.*)"#', $name, $matches); + } + + $normalized = \strtr($matches['test'], '\\:', '-_'); + + if (isset($matches['dataset'])) { + $normalized .= '__data-set-'.\preg_replace('/\W+/', '-', $matches['dataset']); + } + + return $normalized; + } +} diff --git a/src/Browser/Artifact/ArtifactSink.php b/src/Browser/Artifact/ArtifactSink.php new file mode 100644 index 0000000..136f1f0 --- /dev/null +++ b/src/Browser/Artifact/ArtifactSink.php @@ -0,0 +1,27 @@ + + * + * 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 + */ +interface ArtifactSink +{ + /** + * @param array> $savedArtifacts indexed by test name then category + */ + public function writeSummary(array $savedArtifacts): void; +} diff --git a/src/Browser/Artifact/EchoArtifactSink.php b/src/Browser/Artifact/EchoArtifactSink.php new file mode 100644 index 0000000..d8c826b --- /dev/null +++ b/src/Browser/Artifact/EchoArtifactSink.php @@ -0,0 +1,42 @@ + + * + * 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 + */ +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}:"; + } + } + } + } +} diff --git a/src/Browser/Artifact/FailureType.php b/src/Browser/Artifact/FailureType.php new file mode 100644 index 0000000..3ee9230 --- /dev/null +++ b/src/Browser/Artifact/FailureType.php @@ -0,0 +1,25 @@ + + * + * 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 + */ +enum FailureType: string +{ + case Error = 'error'; + case Failure = 'failure'; +} diff --git a/src/Browser/Bridge/Behat/BrowserExtension.php b/src/Browser/Bridge/Behat/BrowserExtension.php new file mode 100644 index 0000000..1146507 --- /dev/null +++ b/src/Browser/Bridge/Behat/BrowserExtension.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat; + +use Behat\Behat\Context\ServiceContainer\ContextExtension; +use Behat\Testwork\EventDispatcher\ServiceContainer\EventDispatcherExtension; +use Behat\Testwork\ServiceContainer\Extension; +use Behat\Testwork\ServiceContainer\ExtensionManager; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Zenstruck\Browser\Artifact\ArtifactCollector; +use Zenstruck\Browser\Artifact\ArtifactSink; +use Zenstruck\Browser\Bridge\Behat\EventListener\ArtifactListener; +use Zenstruck\Browser\Bridge\Behat\Initializer\BrowserContextInitializer; +use Zenstruck\Browser\Bridge\Behat\Kernel\StandaloneKernelBooter; +use Zenstruck\Browser\Bridge\Behat\Kernel\SymfonyExtensionKernelBooter; +use Zenstruck\Browser\Bridge\Behat\Output\BehatOutputArtifactSink; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserOptions; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * Behat extension that wires the zenstruck/browser Behat bridge: registers the + * browser factory, the context initializer, and the artifact-capture listener. + * + * Selects the {@see KernelBooter} implementation at compile time: when + * `friends-of-behat/symfony-extension` is enabled in the same `behat.yml`, the + * {@see SymfonyExtensionKernelBooter} pulls the kernel from that extension; + * otherwise the {@see StandaloneKernelBooter} boots the kernel itself from + * env vars (KERNEL_CLASS, APP_ENV, APP_DEBUG). + * + * @author Hugo Hamon + */ +final class BrowserExtension implements Extension +{ + private const /*string*/ FOB_SYMFONY_EXTENSION = 'FriendsOfBehat\\SymfonyExtension\\ServiceContainer\\SymfonyExtension'; + + public function getConfigKey(): string + { + return 'zenstruck_browser'; + } + + public function initialize(ExtensionManager $extensionManager): void + { + } + + public function configure(ArrayNodeDefinition $builder): void + { + $builder + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('kernel_class')->defaultNull()->end() + ->scalarNode('env')->defaultValue('test')->end() + ->booleanNode('debug')->defaultTrue()->end() + ->scalarNode('kernel_browser_class')->defaultNull()->end() + ->scalarNode('panther_browser_class')->defaultNull()->end() + ->scalarNode('source_dir')->defaultValue('./var/browser/source')->end() + ->booleanNode('source_debug')->defaultFalse()->end() + ->booleanNode('follow_redirects')->defaultTrue()->end() + ->booleanNode('catch_exceptions')->defaultTrue()->end() + ->scalarNode('screenshot_dir')->defaultValue('./var/browser/screenshots')->end() + ->scalarNode('console_log_dir')->defaultValue('./var/browser/console-logs')->end() + ->booleanNode('always_start_webserver')->defaultFalse()->end() + ->scalarNode('panther_browser')->defaultNull()->end() + ->end() + ; + } + + public function load(ContainerBuilder $container, array $config): void + { + $container + ->setDefinition(BrowserRegistry::class, new Definition(BrowserRegistry::class)) + ->setPublic(false) + ; + + $container + ->setDefinition( + BrowserOptions::class, + new Definition(BrowserOptions::class, [ + $config['kernel_browser_class'], + $config['panther_browser_class'], + $config['source_dir'], + $config['source_debug'], + $config['follow_redirects'], + $config['catch_exceptions'], + $config['screenshot_dir'], + $config['console_log_dir'], + $config['always_start_webserver'], + $config['panther_browser'], + ]), + ) + ->setPublic(false); + + $this->registerKernelBooter($container, $config); + + $container + ->setDefinition( + BrowserFactory::class, + new Definition(BrowserFactory::class, [ + new Reference(KernelBooter::class), + new Reference(BrowserRegistry::class), + new Reference(BrowserOptions::class), + ]), + ) + ->setPublic(false); + + $container + ->setDefinition(ArtifactSink::class, new Definition(BehatOutputArtifactSink::class)) + ->setPublic(false); + + $container + ->setDefinition( + ArtifactCollector::class, + new Definition(ArtifactCollector::class, [ + new Reference(BrowserRegistry::class), + new Reference(ArtifactSink::class), + ]), + ) + ->setPublic(false); + + $initializer = new Definition(BrowserContextInitializer::class, [ + new Reference(BrowserFactory::class), + new Reference(BrowserRegistry::class), + new Reference(KernelBooter::class), + ]); + + $initializer->addTag(ContextExtension::INITIALIZER_TAG); + $container->setDefinition(BrowserContextInitializer::class, $initializer); + + $listener = new Definition(ArtifactListener::class, [ + new Reference(ArtifactCollector::class), + new Reference(KernelBooter::class), + ]); + + $listener->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + $container->setDefinition(ArtifactListener::class, $listener); + } + + public function process(ContainerBuilder $container): void + { + } + + private function registerKernelBooter(ContainerBuilder $container, array $config): void + { + if (\class_exists(self::FOB_SYMFONY_EXTENSION)) { + $container + ->setDefinition( + SymfonyExtensionKernelBooter::class, + new Definition(SymfonyExtensionKernelBooter::class, [ + new Reference('fob_symfony.kernel'), + ]) + ) + ->setPublic(false); + + $container->setAlias(KernelBooter::class, SymfonyExtensionKernelBooter::class); + + return; + } + + $container + ->setDefinition( + StandaloneKernelBooter::class, + new Definition(StandaloneKernelBooter::class, [ + $config['kernel_class'], + $config['env'], + $config['debug'], + ]), + ) + ->setPublic(false); + + $container->setAlias(KernelBooter::class, StandaloneKernelBooter::class); + } +} diff --git a/src/Browser/Bridge/Behat/Context/BrowserAware.php b/src/Browser/Bridge/Behat/Context/BrowserAware.php new file mode 100644 index 0000000..2197bbe --- /dev/null +++ b/src/Browser/Bridge/Behat/Context/BrowserAware.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Context; + +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * Implemented by Behat contexts that want a {@see \Zenstruck\Browser\KernelBrowser} + * or {@see \Zenstruck\Browser\PantherBrowser} injected. {@see BrowserAwareTrait} + * provides the default implementation. + * + * @author Hugo Hamon + */ +interface BrowserAware +{ + public function setBrowserServices(BrowserFactory $factory, BrowserRegistry $registry, KernelBooter $booter): void; +} diff --git a/src/Browser/Bridge/Behat/Context/BrowserAwareTrait.php b/src/Browser/Bridge/Behat/Context/BrowserAwareTrait.php new file mode 100644 index 0000000..3f801d8 --- /dev/null +++ b/src/Browser/Bridge/Behat/Context/BrowserAwareTrait.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Context; + +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; +use Zenstruck\Browser\KernelBrowser; +use Zenstruck\Browser\PantherBrowser; + +/** + * Behat-side analog of {@see \Zenstruck\Browser\Test\HasBrowser}. Provides the + * same `browser()` / `pantherBrowser()` API but consumes services injected by + * the {@see \Zenstruck\Browser\Bridge\Behat\Initializer\BrowserContextInitializer}. + * + * @author Hugo Hamon + */ +trait BrowserAwareTrait +{ + private BrowserFactory $browserFactory; + private BrowserRegistry $browserRegistry; + private KernelBooter $browserKernelBooter; + + public function setBrowserServices(BrowserFactory $factory, BrowserRegistry $registry, KernelBooter $booter): void + { + $this->browserFactory = $factory; + $this->browserRegistry = $registry; + $this->browserKernelBooter = $booter; + } + + /** + * @param array $options + * @param array $server + */ + protected function browser(array $options = [], array $server = []): KernelBrowser + { + return $this->browserFactory->createKernelBrowser($options, $server); + } + + /** + * @param array $options + * @param array $kernelOptions + * @param array $managerOptions + */ + protected function pantherBrowser(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherBrowser + { + return $this->browserFactory->createPantherBrowser($options, $kernelOptions, $managerOptions); + } +} diff --git a/src/Browser/Bridge/Behat/EventListener/ArtifactListener.php b/src/Browser/Bridge/Behat/EventListener/ArtifactListener.php new file mode 100644 index 0000000..3e1ce97 --- /dev/null +++ b/src/Browser/Bridge/Behat/EventListener/ArtifactListener.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\EventListener; + +use Behat\Behat\EventDispatcher\Event\AfterScenarioTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Testwork\EventDispatcher\Event\AfterSuiteTested; +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\Tester\Result\TestResult; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Zenstruck\Browser\Artifact\ArtifactCollector; +use Zenstruck\Browser\Artifact\FailureType; +use Zenstruck\Browser\KernelBooter; + +/** + * Wires Behat's suite + scenario events to {@see ArtifactCollector}: captures + * browser state on scenario failure and emits the end-of-suite "Saved Browser + * Artifacts" summary. Also resets the {@see KernelBooter} between scenarios so + * each one starts with a fresh container. + * + * @author Hugo Hamon + */ +final class ArtifactListener implements EventSubscriberInterface +{ + public function __construct( + private readonly ArtifactCollector $collector, + private readonly KernelBooter $booter, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + BeforeSuiteTested::class => ['onBeforeSuite', 0], + BeforeScenarioTested::class => ['onBeforeScenario', 0], + AfterScenarioTested::class => ['onAfterScenario', 0], + AfterSuiteTested::class => ['onAfterSuite', 0], + ]; + } + + public function onBeforeSuite(BeforeSuiteTested $event): void + { + $this->collector->onSuiteStart(); + } + + public function onBeforeScenario(BeforeScenarioTested $event): void + { + $this->booter->reset(); + $this->collector->onScenarioStart($this->formatName($event)); + } + + public function onAfterScenario(AfterScenarioTested $event): void + { + $name = $this->formatName($event); + + if ($event->getTestResult()->getResultCode() === TestResult::FAILED) { + $this->collector->onScenarioFailed($name, FailureType::Failure); + } + + $this->collector->onScenarioFinish($name); + } + + public function onAfterSuite(AfterSuiteTested $event): void + { + $this->collector->onSuiteFinish(); + } + + private function formatName(BeforeScenarioTested|AfterScenarioTested $event): string + { + return \sprintf( + '%s:%d (%s)', + $event->getFeature()->getFile() ?? 'unknown', + $event->getScenario()->getLine(), + $event->getScenario()->getTitle() ?? 'untitled', + ); + } +} diff --git a/src/Browser/Bridge/Behat/Initializer/BrowserContextInitializer.php b/src/Browser/Bridge/Behat/Initializer/BrowserContextInitializer.php new file mode 100644 index 0000000..b05e6e6 --- /dev/null +++ b/src/Browser/Bridge/Behat/Initializer/BrowserContextInitializer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Initializer; + +use Behat\Behat\Context\Context; +use Behat\Behat\Context\Initializer\ContextInitializer; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * Injects the browser services into any Behat context that implements + * {@see BrowserAware}. + * + * @author Hugo Hamon + */ +final class BrowserContextInitializer implements ContextInitializer +{ + public function __construct( + private readonly BrowserFactory $factory, + private readonly BrowserRegistry $registry, + private readonly KernelBooter $booter, + ) { + } + + public function initializeContext(Context $context): void + { + if (!$context instanceof BrowserAware) { + return; + } + + $context->setBrowserServices($this->factory, $this->registry, $this->booter); + } +} diff --git a/src/Browser/Bridge/Behat/Kernel/PantherClientFactory.php b/src/Browser/Bridge/Behat/Kernel/PantherClientFactory.php new file mode 100644 index 0000000..357f73a --- /dev/null +++ b/src/Browser/Bridge/Behat/Kernel/PantherClientFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Kernel; + +use Symfony\Component\Panther\Client; +use Symfony\Component\Panther\PantherTestCase; + +/** + * Tiny adapter that extends {@see PantherTestCase} solely to gain access to its + * `protected static` `createPantherClient()` / `createAdditionalPantherClient()` + * methods. Used by the Behat kernel booters because Panther's PHPUnit-rooted + * API can't be called from outside the test-case hierarchy. + * + * @author Hugo Hamon + */ +final class PantherClientFactory extends PantherTestCase +{ + /** + * @param array $options + * @param array $kernelOptions + * @param array $managerOptions + */ + public static function createPrimary(array $options = [], array $kernelOptions = [], array $managerOptions = []): Client + { + return self::createPantherClient($options, $kernelOptions, $managerOptions); + } + + public static function createAdditional(): Client + { + return self::createAdditionalPantherClient(); + } +} diff --git a/src/Browser/Bridge/Behat/Kernel/StandaloneKernelBooter.php b/src/Browser/Bridge/Behat/Kernel/StandaloneKernelBooter.php new file mode 100644 index 0000000..56593be --- /dev/null +++ b/src/Browser/Bridge/Behat/Kernel/StandaloneKernelBooter.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Kernel; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\PantherTestCase; +use Zenstruck\Browser\KernelBooter; + +/** + * Boots the Symfony kernel directly from env vars (KERNEL_CLASS, APP_ENV, + * APP_DEBUG) — used when `friends-of-behat/symfony-extension` is not installed + * or not enabled. The kernel is rebuilt between scenarios via {@see reset()}. + * + * @author Hugo Hamon + */ +final class StandaloneKernelBooter implements KernelBooter +{ + /** @var class-string */ + private readonly string $kernelClass; + + private ?KernelInterface $kernel = null; + private ?PantherClient $primaryPantherClient = null; + + public function __construct( + ?string $kernelClass = null, + private readonly string $environment = 'test', + private readonly bool $debug = true, + ) { + $kernelClass ??= $_SERVER['KERNEL_CLASS'] ?? null; + + if (!\is_string($kernelClass) || !\is_a($kernelClass, KernelInterface::class, true)) { + throw new \RuntimeException(\sprintf('The "kernel_class" option (or KERNEL_CLASS env var) must reference a class that implements %s.', KernelInterface::class)); + } + + $this->kernelClass = $kernelClass; + } + + public function createKernelBrowserClient(array $options = [], array $server = []): SymfonyKernelBrowser + { + $kernel = $this->bootKernel(); + + $container = $kernel->getContainer(); + + if (!$container->has('test.client')) { + throw new \RuntimeException('The Symfony test client is not enabled. Enable framework.test in your test config.'); + } + + $client = $container->get('test.client'); + \assert($client instanceof SymfonyKernelBrowser); + $client->setServerParameters($server); + + return $client; + } + + public function createPantherClient(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherClient + { + if (!\class_exists(PantherTestCase::class)) { + throw new \LogicException('symfony/panther must be installed to use the PantherBrowser.'); + } + + if ($this->primaryPantherClient instanceof PantherClient) { + return PantherClientFactory::createAdditional(); + } + + return $this->primaryPantherClient = PantherClientFactory::createPrimary($options, $kernelOptions, $managerOptions); + } + + public function supportsPanther(): bool + { + return \class_exists(PantherTestCase::class); + } + + public function reset(): void + { + if ($this->kernel instanceof KernelInterface) { + $this->kernel->shutdown(); + $this->kernel = null; + } + + $this->primaryPantherClient = null; + } + + private function bootKernel(): KernelInterface + { + if ($this->kernel instanceof KernelInterface) { + return $this->kernel; + } + + $kernel = new $this->kernelClass($this->environment, $this->debug); + $kernel->boot(); + + return $this->kernel = $kernel; + } +} diff --git a/src/Browser/Bridge/Behat/Kernel/SymfonyExtensionKernelBooter.php b/src/Browser/Bridge/Behat/Kernel/SymfonyExtensionKernelBooter.php new file mode 100644 index 0000000..59263e0 --- /dev/null +++ b/src/Browser/Bridge/Behat/Kernel/SymfonyExtensionKernelBooter.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Kernel; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\PantherTestCase; +use Zenstruck\Browser\KernelBooter; + +/** + * Booter that consumes the Symfony kernel managed by + * `friends-of-behat/symfony-extension`. The kernel is rebooted between + * scenarios so each one gets a fresh container. + * + * @author Hugo Hamon + */ +final class SymfonyExtensionKernelBooter implements KernelBooter +{ + private ?PantherClient $primaryPantherClient = null; + + public function __construct( + private readonly KernelInterface $kernel, + ) { + } + + public function createKernelBrowserClient(array $options = [], array $server = []): SymfonyKernelBrowser + { + if (!$this->kernel->getContainer()->has('test.client')) { + $this->kernel->boot(); + } + + $container = $this->kernel->getContainer(); + + if (!$container->has('test.client')) { + throw new \RuntimeException('The Symfony test client is not enabled. Enable framework.test in your test config.'); + } + + $client = $container->get('test.client'); + \assert($client instanceof SymfonyKernelBrowser); + $client->setServerParameters($server); + + return $client; + } + + public function createPantherClient(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherClient + { + if (!\class_exists(PantherTestCase::class)) { + throw new \LogicException('symfony/panther must be installed to use the PantherBrowser.'); + } + + if ($this->primaryPantherClient instanceof PantherClient) { + return PantherClientFactory::createAdditional(); + } + + return $this->primaryPantherClient = PantherClientFactory::createPrimary($options, $kernelOptions, $managerOptions); + } + + public function supportsPanther(): bool + { + return \class_exists(PantherTestCase::class); + } + + public function reset(): void + { + $this->kernel->shutdown(); + $this->primaryPantherClient = null; + } +} diff --git a/src/Browser/Bridge/Behat/Output/BehatOutputArtifactSink.php b/src/Browser/Bridge/Behat/Output/BehatOutputArtifactSink.php new file mode 100644 index 0000000..94570aa --- /dev/null +++ b/src/Browser/Bridge/Behat/Output/BehatOutputArtifactSink.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Bridge\Behat\Output; + +use Zenstruck\Browser\Artifact\ArtifactSink; + +/** + * Behat-friendly {@see ArtifactSink} that writes the end-of-suite "Saved + * Browser Artifacts" summary directly to STDOUT, bypassing Behat's formatter + * manager so the summary always appears even with `--format=progress`. + * + * @author Hugo Hamon + */ +final class BehatOutputArtifactSink implements ArtifactSink +{ + public function writeSummary(array $savedArtifacts): void + { + if ($savedArtifacts === []) { + return; + } + + $output = "\n\nSaved Browser Artifacts:"; + + foreach ($savedArtifacts as $test => $categories) { + $output .= "\n\n {$test}"; + + foreach ($categories as $category => $artifacts) { + $output .= "\n {$category}:"; + + foreach ($artifacts as $artifact) { + $output .= "\n * {$artifact}:"; + } + } + } + + \fwrite(\STDOUT, $output."\n"); + } +} diff --git a/src/Browser/BrowserFactory.php b/src/Browser/BrowserFactory.php new file mode 100644 index 0000000..6fdfdfc --- /dev/null +++ b/src/Browser/BrowserFactory.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser; + +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\PantherTestCase; + +/** + * Test-framework-agnostic factory that turns a {@see KernelBooter} into ready-to-use + * {@see KernelBrowser} and {@see PantherBrowser} instances, registering each + * created browser into the {@see BrowserRegistry} so the artifact-capture + * lifecycle can dump their state on failure. + * + * @author Hugo Hamon + */ +final class BrowserFactory +{ + public function __construct( + private readonly KernelBooter $kernelBooter, + private readonly BrowserRegistry $registry, + private readonly BrowserOptions $options, + ) { + } + + /** + * @param array $options Kernel boot options + * @param array $server Server parameters + */ + public function createKernelBrowser(array $options = [], array $server = []): KernelBrowser + { + $class = $this->options->kernelBrowserClass ?? KernelBrowser::class; + + if (!\is_a($class, KernelBrowser::class, true)) { + throw new \LogicException(\sprintf('"KERNEL_BROWSER_CLASS" env variable must reference a class that extends %s.', KernelBrowser::class)); + } + + $client = $this->kernelBooter->createKernelBrowserClient($options, $server); + + $browser = new $class($client, $this->options->toKernelBrowserOptions()); + + $this->registry->register($browser); + + return $browser; + } + + /** + * @param array $options + * @param array $kernelOptions + * @param array $managerOptions + */ + public function createPantherBrowser(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherBrowser + { + if (!\class_exists(PantherClient::class)) { + throw new \LogicException('symfony/panther must be installed to use the PantherBrowser (composer require symfony/panther).'); + } + + $class = $this->options->pantherBrowserClass ?? PantherBrowser::class; + + if (!\is_a($class, PantherBrowser::class, true)) { + throw new \LogicException(\sprintf('"PANTHER_BROWSER_CLASS" env variable must reference a class that extends %s.', PantherBrowser::class)); + } + + if ($this->options->alwaysStartWebserver) { + $_SERVER['PANTHER_APP_ENV'] = $_SERVER['APP_ENV'] ?? 'test'; + $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL'] = ''; + } + + $clientOptions = \array_merge( + ['browser' => $this->options->pantherBrowser ?? PantherTestCase::CHROME], + $options, + ); + + $client = $this->kernelBooter->createPantherClient($clientOptions, $kernelOptions, $managerOptions); + + $browser = new $class($client, $this->options->toPantherBrowserOptions()); + + $this->registry->register($browser); + + return $browser; + } +} diff --git a/src/Browser/BrowserOptions.php b/src/Browser/BrowserOptions.php new file mode 100644 index 0000000..032fd43 --- /dev/null +++ b/src/Browser/BrowserOptions.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser; + +/** + * Value object holding the env-var-driven options used by {@see BrowserFactory} + * to construct {@see KernelBrowser} and {@see PantherBrowser} instances. + * + * @author Hugo Hamon + */ +final readonly class BrowserOptions +{ + public function __construct( + public ?string $kernelBrowserClass = null, + public ?string $pantherBrowserClass = null, + public string $sourceDir = './var/browser/source', + public bool $sourceDebug = false, + public bool $followRedirects = true, + public bool $catchExceptions = true, + public string $screenshotDir = './var/browser/screenshots', + public string $consoleLogDir = './var/browser/console-logs', + public bool $alwaysStartWebserver = false, + public ?string $pantherBrowser = null, + ) { + } + + public static function fromEnv(): self + { + return new self( + kernelBrowserClass: $_SERVER['KERNEL_BROWSER_CLASS'] ?? null, + pantherBrowserClass: $_SERVER['PANTHER_BROWSER_CLASS'] ?? null, + sourceDir: $_SERVER['BROWSER_SOURCE_DIR'] ?? './var/browser/source', + sourceDebug: (bool) ($_SERVER['BROWSER_SOURCE_DEBUG'] ?? false), + followRedirects: (bool) ($_SERVER['BROWSER_FOLLOW_REDIRECTS'] ?? true), + catchExceptions: (bool) ($_SERVER['BROWSER_CATCH_EXCEPTIONS'] ?? true), + screenshotDir: $_SERVER['BROWSER_SCREENSHOT_DIR'] ?? './var/browser/screenshots', + consoleLogDir: $_SERVER['BROWSER_CONSOLE_LOG_DIR'] ?? './var/browser/console-logs', + alwaysStartWebserver: (bool) ($_SERVER['BROWSER_ALWAYS_START_WEBSERVER'] ?? false), + pantherBrowser: $_SERVER['PANTHER_BROWSER'] ?? null, + ); + } + + /** + * @return array + */ + public function toKernelBrowserOptions(): array + { + return [ + 'source_dir' => $this->sourceDir, + 'source_debug' => $this->sourceDebug, + 'follow_redirects' => $this->followRedirects, + 'catch_exceptions' => $this->catchExceptions, + ]; + } + + /** + * @return array + */ + public function toPantherBrowserOptions(): array + { + return [ + 'source_dir' => $this->sourceDir, + 'source_debug' => $this->sourceDebug, + 'screenshot_dir' => $this->screenshotDir, + 'console_log_dir' => $this->consoleLogDir, + ]; + } +} diff --git a/src/Browser/BrowserRegistry.php b/src/Browser/BrowserRegistry.php new file mode 100644 index 0000000..06c5110 --- /dev/null +++ b/src/Browser/BrowserRegistry.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser; + +use Zenstruck\Browser; + +/** + * Tracks {@see Browser} instances created during a single test or scenario so + * that the lifecycle hooks ({@see Artifact\ArtifactCollector}) can dump their + * state on failure and collect their saved artifacts on finish. + * + * @author Hugo Hamon + */ +final class BrowserRegistry +{ + private static ?self $default = null; + + /** @var list */ + private array $browsers = []; + private bool $started = false; + + public static function default(): self + { + return self::$default ??= new self(); + } + + /** + * @internal + */ + public static function resetDefault(): void + { + self::$default = null; + } + + public function start(): void + { + $this->started = true; + } + + public function stop(): void + { + $this->started = false; + $this->browsers = []; + } + + public function isStarted(): bool + { + return $this->started; + } + + public function register(Browser $browser): void + { + if (!$this->started) { + return; + } + + $this->browsers[] = $browser; + } + + /** + * @return list + */ + public function all(): array + { + return $this->browsers; + } + + public function clear(): void + { + $this->browsers = []; + } + + public function isEmpty(): bool + { + return $this->browsers === []; + } +} diff --git a/src/Browser/KernelBooter.php b/src/Browser/KernelBooter.php new file mode 100644 index 0000000..e2d624f --- /dev/null +++ b/src/Browser/KernelBooter.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\Panther\Client as PantherClient; + +/** + * Abstracts how the underlying Symfony kernel is booted to obtain a test client + * for the current test or scenario. Decouples {@see BrowserFactory} from any + * particular test-runner (PHPUnit, Behat, ...). + * + * @author Hugo Hamon + */ +interface KernelBooter +{ + /** + * @param array $options Kernel boot options + * @param array $server Server parameters (REMOTE_ADDR, HTTPS, ...) + */ + public function createKernelBrowserClient(array $options = [], array $server = []): SymfonyKernelBrowser; + + /** + * Returns the primary Panther client the first time it is called, and an + * additional client on subsequent calls within the same test or scenario. + * + * @param array $options + * @param array $kernelOptions + * @param array $managerOptions + */ + public function createPantherClient(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherClient; + + public function supportsPanther(): bool; + + /** + * Releases any kernel/Panther state held between tests or scenarios. + */ + public function reset(): void; +} diff --git a/src/Browser/Test/BootstrappedExtension.php b/src/Browser/Test/BootstrappedExtension.php index 92e2f15..0b4f60d 100644 --- a/src/Browser/Test/BootstrappedExtension.php +++ b/src/Browser/Test/BootstrappedExtension.php @@ -28,11 +28,13 @@ use PHPUnit\Runner\Extension\ParameterCollection; use PHPUnit\TextUI\Configuration\Configuration; use Zenstruck\Browser; +use Zenstruck\Browser\BrowserRegistry; class BootstrappedExtension { public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { + BrowserRegistry::resetDefault(); $extension = new LegacyExtension(); $facade->registerSubscriber(new class($extension) implements TestRunnerStartedSubscriber { @@ -133,9 +135,11 @@ public static function testName(Test $test): string /** * @internal + * + * @deprecated since 1.10, use {@see BrowserRegistry::default()}->register() instead. */ public static function registerBrowser(Browser $browser): void { - LegacyExtension::registerBrowser($browser); + BrowserRegistry::default()->register($browser); } } diff --git a/src/Browser/Test/HasBrowser.php b/src/Browser/Test/HasBrowser.php index a32053c..b67f8c1 100644 --- a/src/Browser/Test/HasBrowser.php +++ b/src/Browser/Test/HasBrowser.php @@ -12,11 +12,15 @@ namespace Zenstruck\Browser\Test; use PHPUnit\Framework\Attributes\After; +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Panther\Client as PantherClient; use Symfony\Component\Panther\PantherTestCase; use Symfony\Component\Panther\PantherTestCaseTrait; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserOptions; +use Zenstruck\Browser\BrowserRegistry; use Zenstruck\Browser\KernelBrowser; use Zenstruck\Browser\PantherBrowser; @@ -25,7 +29,7 @@ */ trait HasBrowser { - private static ?PantherClient $primaryPantherClient = null; + private static ?PhpUnitKernelBooter $browserKernelBooter = null; /** * @internal @@ -35,7 +39,8 @@ trait HasBrowser #[After] final public static function _resetBrowserClients(): void { - self::$primaryPantherClient = null; + self::$browserKernelBooter?->reset(); + self::$browserKernelBooter = null; } /** @@ -51,39 +56,7 @@ protected function pantherBrowser(array $options = [], array $kernelOptions = [] throw new \LogicException(\sprintf('A PantherBrowser can only be created in TestCases that extend "%s" or use "%s".', PantherTestCase::class, PantherTestCaseTrait::class)); } - $class = $_SERVER['PANTHER_BROWSER_CLASS'] ?? PantherBrowser::class; - - if (!\is_a($class, PantherBrowser::class, true)) { - throw new \LogicException(\sprintf('"PANTHER_BROWSER_CLASS" env variable must reference a class that extends %s.', PantherBrowser::class)); - } - - $browserOptions = [ - 'source_dir' => $_SERVER['BROWSER_SOURCE_DIR'] ?? './var/browser/source', - 'source_debug' => $_SERVER['BROWSER_SOURCE_DEBUG'] ?? false, - 'screenshot_dir' => $_SERVER['BROWSER_SCREENSHOT_DIR'] ?? './var/browser/screenshots', - 'console_log_dir' => $_SERVER['BROWSER_CONSOLE_LOG_DIR'] ?? './var/browser/console-logs', - ]; - - if ($_SERVER['BROWSER_ALWAYS_START_WEBSERVER'] ?? null) { - $_SERVER['PANTHER_APP_ENV'] = $_SERVER['APP_ENV'] ?? 'test'; // use current environment - $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL'] = ''; // ignore existing server running with Symfony CLI - } - - if (self::$primaryPantherClient) { - $browser = new $class(static::createAdditionalPantherClient(), $browserOptions); // @phpstan-ignore staticMethod.notFound - } else { - self::$primaryPantherClient = static::createPantherClient( - \array_merge(['browser' => $_SERVER['PANTHER_BROWSER'] ?? PantherTestCase::CHROME], $options), - $kernelOptions, - $managerOptions, - ); - - $browser = new $class(self::$primaryPantherClient, $browserOptions); - } - - BrowserExtension::registerBrowser($browser); - - return $browser; + return $this->browserFactory()->createPantherBrowser($options, $kernelOptions, $managerOptions); } /** @@ -95,39 +68,41 @@ protected function browser(array $options = [], array $server = []): KernelBrows throw new \LogicException(\sprintf('A KernelBrowser can only be created in TestCases that extend "%s".', KernelTestCase::class)); } - $class = $_SERVER['KERNEL_BROWSER_CLASS'] ?? KernelBrowser::class; - - if (!\is_a($class, KernelBrowser::class, true)) { - throw new \LogicException(\sprintf('"KERNEL_BROWSER_CLASS" env variable must reference a class that extends %s.', KernelBrowser::class)); - } + return $this->browserFactory()->createKernelBrowser($options, $server); + } - $browserOptions = [ - 'source_dir' => $_SERVER['BROWSER_SOURCE_DIR'] ?? './var/browser/source', - 'source_debug' => $_SERVER['BROWSER_SOURCE_DEBUG'] ?? false, - 'follow_redirects' => (bool) ($_SERVER['BROWSER_FOLLOW_REDIRECTS'] ?? true), - 'catch_exceptions' => (bool) ($_SERVER['BROWSER_CATCH_EXCEPTIONS'] ?? true), - ]; + private function browserFactory(): BrowserFactory + { + $booter = self::$browserKernelBooter ??= new PhpUnitKernelBooter( + createKernelBrowserClient: function (array $options, array $server): SymfonyKernelBrowser { + if ($this instanceof WebTestCase) { + static::ensureKernelShutdown(); - if ($this instanceof WebTestCase) { - static::ensureKernelShutdown(); + return static::createClient($options, $server); // @phpstan-ignore staticMethod.notFound, return.type + } - $browser = new $class(static::createClient($options, $server), $browserOptions); // @phpstan-ignore staticMethod.notFound - } else { - // reboot kernel before starting browser - static::bootKernel($options); + static::bootKernel($options); - if (!static::getContainer()->has('test.client')) { - throw new \RuntimeException('The Symfony test client is not enabled.'); - } + $container = static::getContainer(); - $client = static::getContainer()->get('test.client'); - $client->setServerParameters($server); + if (!$container->has('test.client')) { + throw new \RuntimeException('The Symfony test client is not enabled.'); + } - $browser = new $class($client, $browserOptions); - } + $client = $container->get('test.client'); + \assert($client instanceof SymfonyKernelBrowser); + $client->setServerParameters($server); - BrowserExtension::registerBrowser($browser); + return $client; + }, + createPantherClient: \method_exists(static::class, 'createPantherClient') + ? static fn (array $options, array $kernelOptions, array $managerOptions): PantherClient => static::createPantherClient($options, $kernelOptions, $managerOptions) // @phpstan-ignore staticMethod.notFound + : null, + createAdditionalPantherClient: \method_exists(static::class, 'createAdditionalPantherClient') + ? static fn (): PantherClient => static::createAdditionalPantherClient() // @phpstan-ignore staticMethod.notFound + : null, + ); - return $browser; + return new BrowserFactory($booter, BrowserRegistry::default(), BrowserOptions::fromEnv()); } } diff --git a/src/Browser/Test/LegacyExtension.php b/src/Browser/Test/LegacyExtension.php index 94f1d26..32fcd81 100644 --- a/src/Browser/Test/LegacyExtension.php +++ b/src/Browser/Test/LegacyExtension.php @@ -12,127 +12,64 @@ namespace Zenstruck\Browser\Test; use Zenstruck\Browser; +use Zenstruck\Browser\Artifact\ArtifactCollector; +use Zenstruck\Browser\Artifact\EchoArtifactSink; +use Zenstruck\Browser\Artifact\FailureType; +use Zenstruck\Browser\BrowserRegistry; /** + * Thin PHPUnit (<10) adapter forwarding lifecycle events to the framework-agnostic + * {@see ArtifactCollector}. Also forwards the PHPUnit-10 subscribers wired in + * {@see BootstrappedExtension}. + * * @author Kevin Bond */ class LegacyExtension { - /** @var Browser[] */ - private static array $registeredBrowsers = []; - private static bool $enabled = false; + private readonly ArtifactCollector $collector; - /** @var array> */ - private array $savedArtifacts = []; + public function __construct() + { + $this->collector = new ArtifactCollector(BrowserRegistry::default(), new EchoArtifactSink()); + } /** * @internal + * + * @deprecated since 1.10, use {@see BrowserRegistry::default()}->register() instead. */ public static function registerBrowser(Browser $browser): void { - if (!self::$enabled) { - return; - } - - self::$registeredBrowsers[] = $browser; + BrowserRegistry::default()->register($browser); } public function executeBeforeFirstTest(): void { - self::$enabled = true; + $this->collector->onSuiteStart(); } public function executeBeforeTest(string $test): void { - self::reset(); + $this->collector->onScenarioStart($test); } public function executeAfterTest(string $test, float $time): void { - foreach (self::$registeredBrowsers as $browser) { - foreach ($browser->savedArtifacts() as $category => $artifacts) { - if (!\count($artifacts)) { - continue; - } - - $this->savedArtifacts[$test][$category] = $artifacts; - } - } - - self::reset(); + $this->collector->onScenarioFinish($test); } public function executeAfterLastTest(): void { - if (empty($this->savedArtifacts)) { - return; - } - - echo "\n\nSaved Browser Artifacts:"; - - foreach ($this->savedArtifacts as $test => $categories) { - echo "\n\n {$test}"; - - foreach ($categories as $category => $artifacts) { - echo "\n {$category}:"; - - foreach ($artifacts as $artifact) { - echo "\n * {$artifact}:"; - } - } - } + $this->collector->onSuiteFinish(); } public function executeAfterTestError(string $test, string $message, float $time): void { - self::saveBrowserStates($test, 'error'); + $this->collector->onScenarioFailed($test, FailureType::Error); } public function executeAfterTestFailure(string $test, string $message, float $time): void { - self::saveBrowserStates($test, 'failure'); - } - - private static function saveBrowserStates(string $test, string $type): void - { - if (empty(self::$registeredBrowsers)) { - return; - } - - $filename = \sprintf('%s_%s', $type, self::normalizeTestName($test)); - - foreach (self::$registeredBrowsers as $i => $browser) { - try { - $browser->saveCurrentState("{$filename}__{$i}"); - } catch (\Throwable $e) { - // noop - swallow exceptions related to dumping the current state so as to not - // lose the actual error/failure. - } - } - } - - private static function normalizeTestName(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('#^(?[\w:\\\]+) with data set \#(?\d+)#', $name, $matches)) { - \preg_match('#^(?[\w:\\\]+) with data set "(?.*)"#', $name, $matches); - } - - $normalized = \strtr($matches['test'], '\\:', '-_'); - - if (isset($matches['dataset'])) { - $normalized .= '__data-set-'.\preg_replace('/\W+/', '-', $matches['dataset']); - } - - return $normalized; - } - - private static function reset(): void - { - self::$registeredBrowsers = []; + $this->collector->onScenarioFailed($test, FailureType::Failure); } } diff --git a/src/Browser/Test/PhpUnitKernelBooter.php b/src/Browser/Test/PhpUnitKernelBooter.php new file mode 100644 index 0000000..22c9147 --- /dev/null +++ b/src/Browser/Test/PhpUnitKernelBooter.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Test; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\PantherTestCase; +use Symfony\Component\Panther\PantherTestCaseTrait; +use Zenstruck\Browser\KernelBooter; + +/** + * {@see KernelBooter} fed by closures captured inside the {@see HasBrowser} + * trait. The closures live in the trait's host-class scope so they can reach + * the protected static methods on {@see \Symfony\Bundle\FrameworkBundle\Test\KernelTestCase} + * and {@see PantherTestCase} (which cannot be invoked from a foreign class). + * + * @author Hugo Hamon + */ +final class PhpUnitKernelBooter implements KernelBooter +{ + private ?PantherClient $primaryPantherClient = null; + + /** + * @param \Closure(array, array): SymfonyKernelBrowser $createKernelBrowserClient + * @param ?\Closure(array, array, array): PantherClient $createPantherClient + * @param ?\Closure(): PantherClient $createAdditionalPantherClient + */ + public function __construct( + private readonly \Closure $createKernelBrowserClient, + private readonly ?\Closure $createPantherClient = null, + private readonly ?\Closure $createAdditionalPantherClient = null, + ) { + } + + public function createKernelBrowserClient(array $options = [], array $server = []): SymfonyKernelBrowser + { + return ($this->createKernelBrowserClient)($options, $server); + } + + public function createPantherClient(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherClient + { + if ($this->createPantherClient === null) { + throw new \LogicException(\sprintf('A PantherBrowser can only be created in TestCases that extend "%s" or use "%s".', PantherTestCase::class, PantherTestCaseTrait::class)); + } + + if ($this->primaryPantherClient instanceof PantherClient) { + \assert($this->createAdditionalPantherClient !== null); + + return ($this->createAdditionalPantherClient)(); + } + + return $this->primaryPantherClient = ($this->createPantherClient)($options, $kernelOptions, $managerOptions); + } + + public function supportsPanther(): bool + { + return $this->createPantherClient !== null; + } + + public function reset(): void + { + $this->primaryPantherClient = null; + } +} diff --git a/stubs/BrowserAwareStub.php b/stubs/BrowserAwareStub.php new file mode 100644 index 0000000..427fddf --- /dev/null +++ b/stubs/BrowserAwareStub.php @@ -0,0 +1,10 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use Behat\Behat\EventDispatcher\Event\AfterScenarioTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\ScenarioLikeInterface; +use Behat\Testwork\EventDispatcher\Event\AfterSuiteTested; +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\Environment\Environment; +use Behat\Testwork\Specification\SpecificationIterator; +use Behat\Testwork\Tester\Result\TestResult; +use Behat\Testwork\Tester\Setup\Teardown; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Zenstruck\Browser; +use Zenstruck\Browser\Artifact\ArtifactCollector; +use Zenstruck\Browser\Artifact\ArtifactSink; +use Zenstruck\Browser\Bridge\Behat\EventListener\ArtifactListener; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * @author Hugo Hamon + */ +final class ArtifactListenerTest extends TestCase +{ + private BrowserRegistry $registry; + private ArtifactSink&MockObject $sink; + private ArtifactCollector $collector; + private KernelBooter&MockObject $booter; + private ArtifactListener $listener; + + protected function setUp(): void + { + BrowserRegistry::resetDefault(); + $this->registry = new BrowserRegistry(); + $this->sink = $this->createMock(ArtifactSink::class); + $this->collector = new ArtifactCollector($this->registry, $this->sink); + $this->booter = $this->createMock(KernelBooter::class); + $this->listener = new ArtifactListener($this->collector, $this->booter); + } + + protected function tearDown(): void + { + BrowserRegistry::resetDefault(); + } + + /** + * @test + */ + public function getSubscribedEvents_returns_expected_events(): void + { + $this->assertSame([ + BeforeSuiteTested::class => ['onBeforeSuite', 0], + BeforeScenarioTested::class => ['onBeforeScenario', 0], + AfterScenarioTested::class => ['onAfterScenario', 0], + AfterSuiteTested::class => ['onAfterSuite', 0], + ], ArtifactListener::getSubscribedEvents()); + } + + /** + * @test + */ + public function onBeforeSuite_starts_registry(): void + { + $this->assertFalse($this->registry->isStarted()); + + $this->listener->onBeforeSuite($this->createBeforeSuiteEvent()); + + $this->assertTrue($this->registry->isStarted()); + } + + /** + * @test + */ + public function onBeforeScenario_resets_kernel_booter(): void + { + $this->booter->expects($this->once())->method('reset'); + + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + } + + /** + * @test + */ + public function onBeforeScenario_clears_registry(): void + { + $this->registry->start(); + $this->registry->register($this->createMock(Browser::class)); + $this->assertFalse($this->registry->isEmpty()); + + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $this->assertTrue($this->registry->isEmpty()); + } + + /** + * @test + */ + public function onAfterScenario_with_failed_result_saves_browser_state(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once())->method('saveCurrentState'); + $browser->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent(resultCode: TestResult::FAILED)); + } + + /** + * @test + */ + public function onAfterScenario_with_failed_result_saves_browser_state_with_formatted_name(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent( + file: 'features/user_login.feature', + line: 15, + title: 'Logging in with valid credentials', + )); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ->with($this->callback(fn(string $filename): bool => + \str_starts_with($filename, 'failure_') + && \str_contains($filename, 'features/user_login.feature') + && \str_contains($filename, 'Logging in with valid credentials') + )) + ; + $browser->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent( + resultCode: TestResult::FAILED, + file: 'features/user_login.feature', + line: 15, + title: 'Logging in with valid credentials', + )); + } + + /** + * @test + */ + public function onAfterScenario_with_failed_result_saves_state_for_each_registered_browser(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $browser1 = $this->createMock(Browser::class); + $browser1->expects($this->once())->method('saveCurrentState'); + $browser1->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser1); + + $browser2 = $this->createMock(Browser::class); + $browser2->expects($this->once())->method('saveCurrentState'); + $browser2->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser2); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent(resultCode: TestResult::FAILED)); + } + + /** + * @test + */ + public function onAfterScenario_with_passed_result_does_not_save_browser_state(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->never())->method('saveCurrentState'); + $browser->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent(resultCode: 0)); + } + + /** + * @test + */ + public function onAfterScenario_with_failed_result_clears_registry_after_finish(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $browser = $this->createMock(Browser::class); + $browser->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser); + + $this->assertFalse($this->registry->isEmpty()); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent(resultCode: TestResult::FAILED)); + + $this->assertTrue($this->registry->isEmpty()); + } + + /** + * @test + */ + public function onAfterScenario_with_passed_result_clears_registry_after_finish(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $browser = $this->createMock(Browser::class); + $browser->method('savedArtifacts')->willReturn([]); + $this->registry->register($browser); + + $this->assertFalse($this->registry->isEmpty()); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent(resultCode: 0)); + + $this->assertTrue($this->registry->isEmpty()); + } + + /** + * @test + */ + public function onAfterScenario_with_empty_registry_and_failed_result_does_not_fail(): void + { + $this->registry->start(); + $this->listener->onBeforeScenario($this->createBeforeScenarioEvent()); + + $this->listener->onAfterScenario($this->createAfterScenarioEvent( + resultCode: TestResult::FAILED, + )); + + $this->assertTrue($this->registry->isEmpty()); + } + + /** + * @test + */ + public function onAfterSuite_stops_registry(): void + { + $this->registry->start(); + $this->assertTrue($this->registry->isStarted()); + + $this->listener->onAfterSuite($this->createAfterSuiteEvent()); + + $this->assertFalse($this->registry->isStarted()); + } + + /** + * @test + */ + public function onAfterSuite_writes_summary_to_sink(): void + { + $this->sink->expects($this->once())->method('writeSummary'); + + $this->listener->onAfterSuite($this->createAfterSuiteEvent()); + } + + /** + * @test + */ + public function onAfterSuite_without_started_registry_does_not_fail(): void + { + $this->sink->expects($this->once())->method('writeSummary'); + + $this->listener->onAfterSuite($this->createAfterSuiteEvent()); + } + + private function createBeforeSuiteEvent(): BeforeSuiteTested + { + return new BeforeSuiteTested( + $this->createMock(Environment::class), + $this->createMock(SpecificationIterator::class), + ); + } + + private function createAfterSuiteEvent(): AfterSuiteTested + { + return new AfterSuiteTested( + $this->createMock(Environment::class), + $this->createMock(SpecificationIterator::class), + $this->createMock(TestResult::class), + $this->createMock(Teardown::class), + ); + } + + private function createBeforeScenarioEvent(?string $file = null, ?int $line = null, ?string $title = null): BeforeScenarioTested + { + return new BeforeScenarioTested( + $this->createMock(Environment::class), + $this->createFeature($file ?? 'features/test.feature'), + $this->createScenario($line ?? 1, $title ?? 'Test scenario'), + ); + } + + private function createAfterScenarioEvent(int $resultCode, ?string $file = null, ?int $line = null, ?string $title = null): AfterScenarioTested + { + $result = $this->createMock(TestResult::class); + $result->method('getResultCode')->willReturn($resultCode); + + return new AfterScenarioTested( + $this->createMock(Environment::class), + $this->createFeature($file ?? 'features/test.feature'), + $this->createScenario($line ?? 1, $title ?? 'Test scenario'), + $result, + $this->createMock(Teardown::class), + ); + } + + private function createFeature(string $file): FeatureNode&MockObject + { + $feature = $this->createMock(FeatureNode::class); + $feature->method('getFile')->willReturn($file); + + return $feature; + } + + private function createScenario(int $line, string $title): ScenarioLikeInterface&MockObject + { + $scenario = $this->createMock(ScenarioLikeInterface::class); + $scenario->method('getLine')->willReturn($line); + $scenario->method('getTitle')->willReturn($title); + + return $scenario; + } +} diff --git a/tests/Behat/Context/BrowserContext.php b/tests/Behat/Context/BrowserContext.php new file mode 100644 index 0000000..21726c0 --- /dev/null +++ b/tests/Behat/Context/BrowserContext.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests\Behat\Context; + +use Behat\Behat\Context\Context; +use Behat\Step\Then; +use Behat\Step\When; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAwareTrait; +use Zenstruck\Browser\KernelBrowser; + +/** + * Fixture Behat context exercising the {@see BrowserAwareTrait} end-to-end + * against the existing {@see \Zenstruck\Browser\Tests\Fixture\Kernel}. + * + * @author Hugo Hamon + */ +final class BrowserContext implements Context, BrowserAware +{ + use BrowserAwareTrait; + + private ?KernelBrowser $currentBrowser = null; + + #[When('I visit :url')] + public function iVisit(string $url): void + { + $this->currentBrowser = $this->browser()->visit($url); + } + + #[Then('I should see :text')] + public function iShouldSee(string $text): void + { + $this->browserOrFail()->assertSee($text); + } + + #[Then('the response body should contain :text')] + public function theResponseBodyShouldContain(string $text): void + { + $this->browserOrFail()->assertContains($text); + } + + #[Then('the response should be successful')] + public function theResponseShouldBeSuccessful(): void + { + $this->browserOrFail()->assertSuccessful(); + } + + #[Then('the response status code should be :code')] + public function theResponseStatusCodeShouldBe(int $code): void + { + $this->browserOrFail()->assertStatus($code); + } + + private function browserOrFail(): KernelBrowser + { + if (!$this->currentBrowser instanceof KernelBrowser) { + throw new \RuntimeException('No browser has been started in this scenario yet.'); + } + + return $this->currentBrowser; + } +} diff --git a/tests/Behat/behat.dist.php b/tests/Behat/behat.dist.php new file mode 100644 index 0000000..d526a6c --- /dev/null +++ b/tests/Behat/behat.dist.php @@ -0,0 +1,32 @@ +withSuite( + (new Suite('default')) + ->withPaths($root.'/features') + ->withContexts(BrowserContext::class), + ) + ->withExtension(new Extension(BrowserExtension::class, [ + 'kernel_class' => Kernel::class, + 'env' => 'test', + 'debug' => true, + 'source_dir' => $varRoot.'/source', + 'screenshot_dir' => $varRoot.'/screenshots', + 'console_log_dir' => $varRoot.'/console-logs', + ])) +; + +return (new Config())->withProfile($default); diff --git a/tests/Behat/behat.yml.dist b/tests/Behat/behat.yml.dist new file mode 100644 index 0000000..de083d3 --- /dev/null +++ b/tests/Behat/behat.yml.dist @@ -0,0 +1,17 @@ +# NOTE: this YAML config is kept only for Behat ^3.x users. +# Behat 4.x requires behat.dist.php (see the sibling file). +default: + suites: + default: + paths: + - '%paths.base%/features' + contexts: + - Zenstruck\Browser\Tests\Behat\Context\BrowserContext + extensions: + Zenstruck\Browser\Bridge\Behat\BrowserExtension: + kernel_class: Zenstruck\Browser\Tests\Fixture\Kernel + env: test + debug: true + source_dir: '%paths.base%/../../var/browser/source' + screenshot_dir: '%paths.base%/../../var/browser/screenshots' + console_log_dir: '%paths.base%/../../var/browser/console-logs' diff --git a/tests/Behat/features/browser.feature b/tests/Behat/features/browser.feature new file mode 100644 index 0000000..1b488fd --- /dev/null +++ b/tests/Behat/features/browser.feature @@ -0,0 +1,14 @@ +Feature: zenstruck/browser inside a Behat scenario + In order to write acceptance tests with the Behat BDD framework + As a Symfony developer + I need the zenstruck/browser fluent API available from a Behat context + + Scenario: Visit a simple page + When I visit "/page2" + Then the response should be successful + And I should see "success" + + Scenario: Visit a text response + When I visit "/text" + Then the response status code should be 200 + And the response body should contain "text content" diff --git a/tests/Behat/var/browser/source/attachment.zip b/tests/Behat/var/browser/source/attachment.zip new file mode 100644 index 0000000..e6ebe53 Binary files /dev/null and b/tests/Behat/var/browser/source/attachment.zip differ diff --git a/tests/Behat/var/browser/source/source.txt b/tests/Behat/var/browser/source/source.txt new file mode 100644 index 0000000..c64ab29 --- /dev/null +++ b/tests/Behat/var/browser/source/source.txt @@ -0,0 +1,69 @@ + + + + + meta title + + + +

h1 title

+ +

exception link

+ + +
    +
  • list 1
  • +
  • list 2
  • +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/BehatOutputArtifactSinkTest.php b/tests/BehatOutputArtifactSinkTest.php new file mode 100644 index 0000000..56a0b6e --- /dev/null +++ b/tests/BehatOutputArtifactSinkTest.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Zenstruck\Browser\Artifact\ArtifactSink; +use Zenstruck\Browser\Bridge\Behat\Output\BehatOutputArtifactSink; + +/** + * @author Hugo Hamon + */ +final class BehatOutputArtifactSinkTest extends TestCase +{ + /** + * @test + */ + public function implements_artifact_sink(): void + { + $this->assertInstanceOf(ArtifactSink::class, new BehatOutputArtifactSink()); + } + + /** + * @test + */ + public function writes_nothing_when_empty(): void + { + (new BehatOutputArtifactSink())->writeSummary([]); + + $this->expectNotToPerformAssertions(); + } + + /** + * @test + */ + public function writes_summary_to_stdout(): void + { + $output = $this->captureStdout(static fn (): array => [ + 'Test::case' => ['html' => ['/tmp/page.html']], + ]); + + $this->assertStringContainsString('Saved Browser Artifacts', $output); + $this->assertStringContainsString('Test::case', $output); + $this->assertStringContainsString('html:', $output); + $this->assertStringContainsString('/tmp/page.html:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_tests(): void + { + $output = $this->captureStdout(static fn (): array => [ + 'First::a' => ['html' => ['/tmp/a.html']], + 'Second::b' => ['html' => ['/tmp/b.html']], + ]); + + $this->assertStringContainsString('First::a', $output); + $this->assertStringContainsString('Second::b', $output); + $this->assertStringContainsString('/tmp/a.html:', $output); + $this->assertStringContainsString('/tmp/b.html:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_categories(): void + { + $output = $this->captureStdout(static fn (): array => [ + 'Test::case' => [ + 'html' => ['/tmp/page.html'], + 'pdf' => ['/tmp/report.pdf'], + ], + ]); + + $this->assertStringContainsString('html:', $output); + $this->assertStringContainsString('pdf:', $output); + $this->assertStringContainsString('/tmp/page.html:', $output); + $this->assertStringContainsString('/tmp/report.pdf:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_artifacts(): void + { + $output = $this->captureStdout(static fn (): array => [ + 'Test::case' => ['html' => ['/tmp/a.html', '/tmp/b.html']], + ]); + + $this->assertStringContainsString('/tmp/a.html:', $output); + $this->assertStringContainsString('/tmp/b.html:', $output); + } + + /** + * @test + */ + public function summary_format_is_correct(): void + { + $output = $this->captureStdout(static fn (): array => [ + 'Test::case' => ['html' => ['/tmp/page.html']], + ]); + + $expected = "\n\nSaved Browser Artifacts:\n\n Test::case\n html:\n * /tmp/page.html:\n"; + $this->assertSame($expected, $output); + } + + /** + * @param callable(): array $inputProvider + */ + private function captureStdout(callable $inputProvider): string + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'browser_stdout_'); + + $phpCode = \sprintf( + 'writeSummary(%s);', + \var_export(\dirname(__DIR__).'/vendor/autoload.php', true), + BehatOutputArtifactSink::class, + \var_export($inputProvider(), true), + ); + + \file_put_contents($tmpFile, $phpCode); + + $output = \shell_exec(\sprintf('php %s 2>&1', \escapeshellarg($tmpFile))); + + \unlink($tmpFile); + + if ($output === null) { + $this->markTestSkipped('shell_exec or php binary not available.'); + } + + return $output; + } +} diff --git a/tests/BootstrappedExtensionTest.php b/tests/BootstrappedExtensionTest.php new file mode 100644 index 0000000..1d49d78 --- /dev/null +++ b/tests/BootstrappedExtensionTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Event\Code\Test; +use PHPUnit\Event\EventFacadeIsSealedException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\Extension\Facade; +use PHPUnit\Runner\Extension\ParameterCollection; +use PHPUnit\TextUI\Configuration\Configuration; +use Zenstruck\Browser; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\Test\BootstrappedExtension; + +/** + * @requires PHPUnit 10.0 + */ +final class BootstrappedExtensionTest extends TestCase +{ + protected function setUp(): void + { + BrowserRegistry::resetDefault(); + } + + /** + * @test + */ + public function bootstrap_resets_browser_registry(): void + { + $original = BrowserRegistry::default(); + + $extension = new BootstrappedExtension(); + + try { + $extension->bootstrap( + (new \ReflectionClass(Configuration::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(Facade::class))->newInstanceWithoutConstructor(), + ParameterCollection::fromArray([]), + ); + } catch (EventFacadeIsSealedException) { + // The EventFacade is sealed during test execution, which prevents + // subscriber registration. BrowserRegistry::resetDefault() is + // called before that, so we can still verify the reset below. + } + + $this->assertNotSame($original, BrowserRegistry::default()); + } + + /** + * @test + */ + public function test_name_returns_name_with_class_for_test_methods(): void + { + $test = new class('/tmp/file.php') extends Test { + public function id(): string + { + return 'Zenstruck\Tests\FooTest::test_bar'; + } + + public function name(): string + { + return 'test_bar'; + } + + public function nameWithClass(): string + { + return 'Zenstruck\Tests\FooTest::test_bar'; + } + + public function isTestMethod(): bool + { + return true; + } + }; + + $this->assertSame( + 'Zenstruck\Tests\FooTest::test_bar', + BootstrappedExtension::testName($test), + ); + } + + /** + * @test + */ + public function test_name_returns_name_for_non_test_methods(): void + { + $test = $this->createMock(Test::class); + $test->method('isTestMethod')->willReturn(false); + $test->method('name')->willReturn('test_bar'); + + $this->assertSame('test_bar', BootstrappedExtension::testName($test)); + } + + /** + * @test + */ + public function register_browser_delegates_to_browser_registry(): void + { + $browser = $this->createMock(Browser::class); + + BrowserRegistry::default()->start(); + BootstrappedExtension::registerBrowser($browser); + + $this->assertSame([$browser], BrowserRegistry::default()->all()); + } +} diff --git a/tests/BrowserAwareTraitTest.php b/tests/BrowserAwareTraitTest.php new file mode 100644 index 0000000..b2f2016 --- /dev/null +++ b/tests/BrowserAwareTraitTest.php @@ -0,0 +1,278 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\ProcessManager\BrowserManagerInterface; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAwareTrait; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserOptions; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; +use Zenstruck\Browser\KernelBrowser; +use Zenstruck\Browser\PantherBrowser; + +final class BrowserAwareContext implements BrowserAware +{ + use BrowserAwareTrait; + + public function callBrowser(array $options = [], array $server = []): KernelBrowser + { + return $this->browser($options, $server); + } + + public function callPantherBrowser(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherBrowser + { + return $this->pantherBrowser($options, $kernelOptions, $managerOptions); + } +} + +/** + * @author Hugo Hamon + */ +final class BrowserAwareTraitTest extends TestCase +{ + private BrowserRegistry $registry; + private KernelBooter& \PHPUnit\Framework\MockObject\MockObject $booter; + private BrowserFactory $factory; + private BrowserAwareContext $context; + + protected function setUp(): void + { + BrowserRegistry::resetDefault(); + $this->registry = new BrowserRegistry(); + $this->registry->start(); + $this->booter = $this->createMock(KernelBooter::class); + $this->factory = new BrowserFactory($this->booter, $this->registry, new BrowserOptions()); + $this->context = new BrowserAwareContext(); + $this->context->setBrowserServices($this->factory, $this->registry, $this->booter); + } + + protected function tearDown(): void + { + BrowserRegistry::resetDefault(); + } + + private function setPropertyAccessible(string $propertyName): \ReflectionProperty + { + return new \ReflectionProperty(BrowserAwareContext::class, $propertyName); + } + + /** + * @test + */ + public function stores_browser_factory(): void + { + $this->assertSame( + $this->factory, + $this->setPropertyAccessible('browserFactory')->getValue($this->context), + ); + } + + /** + * @test + */ + public function stores_browser_registry(): void + { + $this->assertSame( + $this->registry, + $this->setPropertyAccessible('browserRegistry')->getValue($this->context), + ); + } + + /** + * @test + */ + public function stores_kernel_booter(): void + { + $this->assertSame( + $this->booter, + $this->setPropertyAccessible('browserKernelBooter')->getValue($this->context), + ); + } + + /** + * @test + */ + public function browser_returns_kernel_browser_instance(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $this->booter + ->expects($this->once()) + ->method('createKernelBrowserClient') + ->willReturn($client) + ; + + $browser = $this->context->callBrowser(); + + $this->assertInstanceOf(KernelBrowser::class, $browser); + } + + /** + * @test + */ + public function browser_forwards_options_and_server_to_factory(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $this->booter + ->expects($this->once()) + ->method('createKernelBrowserClient') + ->with( + $this->identicalTo(['custom' => 'option']), + $this->identicalTo(['REMOTE_ADDR' => '10.0.0.1']), + ) + ->willReturn($client) + ; + + $this->context->callBrowser( + options: ['custom' => 'option'], + server: ['REMOTE_ADDR' => '10.0.0.1'], + ); + } + + /** + * @test + */ + public function browser_registers_instance_in_registry(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $this->booter->method('createKernelBrowserClient')->willReturn($client); + + $browser = $this->context->callBrowser(); + + $this->assertSame([$browser], $this->registry->all()); + } + + /** + * @test + */ + public function browser_uses_default_empty_options(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $this->booter + ->expects($this->once()) + ->method('createKernelBrowserClient') + ->with([], []) + ->willReturn($client) + ; + + $this->context->callBrowser(); + } + + /** + * @test + */ + public function panther_browser_returns_panther_browser_instance(): void + { + $this->booter->method('createPantherClient')->willReturn($this->createPantherClient()); + + $browser = $this->context->callPantherBrowser(); + + $this->assertInstanceOf(PantherBrowser::class, $browser); + } + + /** + * @test + */ + public function panther_browser_forwards_options_to_factory(): void + { + $this->booter + ->expects($this->once()) + ->method('createPantherClient') + ->with( + $this->callback(fn(array $options): bool => isset($options['custom']) && 'value' === $options['custom']), + $this->identicalTo(['kernel' => 'option']), + $this->identicalTo(['manager' => 'option']), + ) + ->willReturn($this->createPantherClient()) + ; + + $this->context->callPantherBrowser( + options: ['custom' => 'value'], + kernelOptions: ['kernel' => 'option'], + managerOptions: ['manager' => 'option'], + ); + } + + /** + * @test + */ + public function panther_browser_uses_default_empty_options(): void + { + $this->booter + ->expects($this->once()) + ->method('createPantherClient') + ->with( + $this->callback(fn(array $options): bool => isset($options['browser'])), + [], + [], + ) + ->willReturn($this->createPantherClient()) + ; + + $this->context->callPantherBrowser(); + } + + /** + * @test + */ + public function panther_browser_registers_instance_in_registry(): void + { + $this->booter->method('createPantherClient')->willReturn($this->createPantherClient()); + + $browser = $this->context->callPantherBrowser(); + + $this->assertSame([$browser], $this->registry->all()); + } + + /** + * @test + */ + public function throws_error_when_calling_browser_without_services(): void + { + $context = new BrowserAwareContext(); + + $this->expectException(\Error::class); + + $context->callBrowser(); + } + + /** + * @test + */ + public function throws_error_when_calling_panther_browser_without_services(): void + { + $context = new BrowserAwareContext(); + + $this->expectException(\Error::class); + + $context->callPantherBrowser(); + } + + /** + * @param array $options + * @param array $kernelOptions + * @param array $managerOptions + */ + private function createPantherClient(): PantherClient + { + $client = (new \ReflectionClass(PantherClient::class))->newInstanceWithoutConstructor(); + + $managerProperty = new \ReflectionProperty(PantherClient::class, 'browserManager'); + $managerProperty->setValue($client, $this->createMock(BrowserManagerInterface::class)); + + return $client; + } +} diff --git a/tests/BrowserContextInitializerTest.php b/tests/BrowserContextInitializerTest.php new file mode 100644 index 0000000..24bc8e1 --- /dev/null +++ b/tests/BrowserContextInitializerTest.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use Behat\Behat\Context\Context; +use Behat\Behat\Context\Initializer\ContextInitializer; +use PHPUnit\Framework\TestCase; +use Zenstruck\Browser\Bridge\Behat\Context\BrowserAware; +use Zenstruck\Browser\Bridge\Behat\Initializer\BrowserContextInitializer; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserOptions; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * @author Hugo Hamon + */ +final class BrowserContextInitializerTest extends TestCase +{ + /** + * @test + */ + public function implements_context_initializer_interface(): void + { + $this->assertInstanceOf( + ContextInitializer::class, + $this->createInitializer(), + ); + } + + /** + * @test + */ + public function is_final_class(): void + { + $this->assertTrue( + (new \ReflectionClass(BrowserContextInitializer::class))->isFinal(), + ); + } + + /** + * @test + */ + public function does_nothing_when_context_is_not_browser_aware(): void + { + $this->createInitializer()->initializeContext( + $this->createMock(Context::class), + ); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function injects_browser_services_into_browser_aware_context(): void + { + if (!method_exists($this, 'createMockForIntersectionOfInterfaces')) { + $this->markTestSkipped('This version of PHPUnit does not support `createMockForIntersectionOfInterfaces` method'); + } + + $factory = $this->createBrowserFactory(); + $registry = new BrowserRegistry(); + $booter = $this->createMock(KernelBooter::class); + + // The `createMockForIntersectionOfInterfaces` method is only available in PHPUnit 9.3+ + $context = $this->createMockForIntersectionOfInterfaces([Context::class, BrowserAware::class]); + $context->expects($this->once()) + ->method('setBrowserServices') + ->with($factory, $registry, $booter) + ; + + $initializer = new BrowserContextInitializer($factory, $registry, $booter); + $initializer->initializeContext($context); + } + + /** + * @test + */ + public function constructor_parameters_are_promoted_correctly(): void + { + $reflection = new \ReflectionClass(BrowserContextInitializer::class); + $constructor = $reflection->getConstructor(); + + $this->assertNotNull($constructor); + + $params = $constructor->getParameters(); + + $this->assertCount(3, $params); + $this->assertSame('factory', $params[0]->getName()); + $this->assertSame(BrowserFactory::class, $params[0]->getType()->getName()); + $this->assertSame('registry', $params[1]->getName()); + $this->assertSame(BrowserRegistry::class, $params[1]->getType()->getName()); + $this->assertSame('booter', $params[2]->getName()); + $this->assertSame(KernelBooter::class, $params[2]->getType()->getName()); + } + + private function createInitializer(): BrowserContextInitializer + { + return new BrowserContextInitializer( + $this->createBrowserFactory(), + new BrowserRegistry(), + $this->createMock(KernelBooter::class), + ); + } + + private function createBrowserFactory(): BrowserFactory + { + return new BrowserFactory( + $this->createMock(KernelBooter::class), + new BrowserRegistry(), + new BrowserOptions(), + ); + } +} diff --git a/tests/BrowserExtensionTest.php b/tests/BrowserExtensionTest.php new file mode 100644 index 0000000..e550b58 --- /dev/null +++ b/tests/BrowserExtensionTest.php @@ -0,0 +1,440 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use Behat\Behat\Context\ServiceContainer\ContextExtension; +use Behat\Testwork\EventDispatcher\ServiceContainer\EventDispatcherExtension; +use Behat\Testwork\ServiceContainer\ExtensionManager; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Zenstruck\Browser\Bridge\Behat\BrowserExtension; +use Zenstruck\Browser\Bridge\Behat\Initializer\BrowserContextInitializer; +use Zenstruck\Browser\Bridge\Behat\EventListener\ArtifactListener; +use Zenstruck\Browser\Bridge\Behat\Kernel\StandaloneKernelBooter; +use Zenstruck\Browser\Bridge\Behat\Kernel\SymfonyExtensionKernelBooter; +use Zenstruck\Browser\Bridge\Behat\Output\BehatOutputArtifactSink; +use Zenstruck\Browser\Artifact\ArtifactCollector; +use Zenstruck\Browser\Artifact\ArtifactSink; +use Zenstruck\Browser\BrowserFactory; +use Zenstruck\Browser\BrowserOptions; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\KernelBooter; + +/** + * @author Hugo Hamon + */ +final class BrowserExtensionTest extends TestCase +{ + private const FOB_SYMFONY_EXTENSION = 'FriendsOfBehat\\SymfonyExtension\\ServiceContainer\\SymfonyExtension'; + + /** + * @test + */ + public function get_config_key(): void + { + $this->assertSame('zenstruck_browser', (new BrowserExtension())->getConfigKey()); + } + + /** + * @test + */ + public function initialize_does_nothing(): void + { + (new BrowserExtension())->initialize( + new ExtensionManager([]), + ); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function process_does_nothing(): void + { + (new BrowserExtension())->process(new ContainerBuilder()); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function configure_has_default_values(): void + { + $treeBuilder = new TreeBuilder('zenstruck_browser'); + (new BrowserExtension())->configure($treeBuilder->getRootNode()); + + $config = (new Processor())->process($treeBuilder->buildTree(), [[]]); + + $this->assertNull($config['kernel_class']); + $this->assertSame('test', $config['env']); + $this->assertTrue($config['debug']); + $this->assertNull($config['kernel_browser_class']); + $this->assertNull($config['panther_browser_class']); + $this->assertSame('./var/browser/source', $config['source_dir']); + $this->assertFalse($config['source_debug']); + $this->assertTrue($config['follow_redirects']); + $this->assertTrue($config['catch_exceptions']); + $this->assertSame('./var/browser/screenshots', $config['screenshot_dir']); + $this->assertSame('./var/browser/console-logs', $config['console_log_dir']); + $this->assertFalse($config['always_start_webserver']); + $this->assertNull($config['panther_browser']); + } + + /** + * @test + */ + public function configure_accepts_custom_values(): void + { + $treeBuilder = new TreeBuilder('zenstruck_browser'); + (new BrowserExtension())->configure($treeBuilder->getRootNode()); + + $config = (new Processor())->process($treeBuilder->buildTree(), [[ + 'kernel_class' => 'App\\CustomKernel', + 'env' => 'staging', + 'debug' => false, + 'source_dir' => '/custom/source', + 'always_start_webserver' => true, + 'panther_browser' => 'firefox', + ]]); + + $this->assertSame('App\\CustomKernel', $config['kernel_class']); + $this->assertSame('staging', $config['env']); + $this->assertFalse($config['debug']); + $this->assertSame('/custom/source', $config['source_dir']); + $this->assertTrue($config['always_start_webserver']); + $this->assertSame('firefox', $config['panther_browser']); + $this->assertTrue($config['follow_redirects']); + } + + /** + * @test + */ + public function load_registers_expected_services(): void + { + $container = $this->loadExtension(); + + foreach ($this->expectedServiceIds() as $id) { + $this->assertTrue($container->hasDefinition($id), \sprintf('Service "%s" not found.', $id)); + } + } + + /** + * @test + */ + public function load_browser_registry_is_private(): void + { + $definition = $this->loadExtension()->getDefinition(BrowserRegistry::class); + + $this->assertFalse($definition->isPublic()); + } + + /** + * @test + */ + public function load_aliases_kernel_booter_to_standalone_by_default(): void + { + $container = $this->loadExtension(); + + $this->assertTrue($container->hasAlias(KernelBooter::class)); + $this->assertSame( + StandaloneKernelBooter::class, + (string) $container->getAlias(KernelBooter::class), + ); + } + + /** + * @test + */ + public function load_registers_standalone_kernel_booter_with_config(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(StandaloneKernelBooter::class); + + $this->assertSame(StandaloneKernelBooter::class, $definition->getClass()); + $this->assertSame([null, 'test', true], $definition->getArguments()); + } + + /** + * @test + */ + public function load_standalone_kernel_booter_is_private(): void + { + $definition = $this->loadExtension()->getDefinition(StandaloneKernelBooter::class); + + $this->assertFalse($definition->isPublic()); + } + + /** + * @test + */ + public function load_configures_browser_options_with_defaults(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(BrowserOptions::class); + + $this->assertSame(BrowserOptions::class, $definition->getClass()); + $this->assertSame( + [null, null, './var/browser/source', false, true, true, './var/browser/screenshots', './var/browser/console-logs', false, null], + $definition->getArguments(), + ); + } + + /** + * @test + */ + public function load_configures_browser_options_with_custom_values(): void + { + $container = $this->loadExtension([ + 'kernel_browser_class' => 'App\\CustomKernelBrowser', + 'panther_browser_class' => 'App\\CustomPantherBrowser', + 'source_dir' => '/custom/source', + 'source_debug' => true, + 'follow_redirects' => false, + 'catch_exceptions' => false, + 'screenshot_dir' => '/custom/screenshots', + 'console_log_dir' => '/custom/console-logs', + 'always_start_webserver' => true, + 'panther_browser' => 'firefox', + ]); + + $definition = $container->getDefinition(BrowserOptions::class); + + $this->assertSame( + ['App\\CustomKernelBrowser', 'App\\CustomPantherBrowser', '/custom/source', true, false, false, '/custom/screenshots', '/custom/console-logs', true, 'firefox'], + $definition->getArguments(), + ); + } + + /** + * @test + */ + public function load_browser_options_is_private(): void + { + $definition = $this->loadExtension()->getDefinition(BrowserOptions::class); + + $this->assertFalse($definition->isPublic()); + } + + /** + * @test + */ + public function load_browser_factory_references_booter_registry_and_options(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(BrowserFactory::class); + + $this->assertSame(BrowserFactory::class, $definition->getClass()); + $this->assertCount(3, $definition->getArguments()); + $this->assertReference(KernelBooter::class, $definition->getArgument(0)); + $this->assertReference(BrowserRegistry::class, $definition->getArgument(1)); + $this->assertReference(BrowserOptions::class, $definition->getArgument(2)); + } + + /** + * @test + */ + public function load_browser_factory_is_private(): void + { + $this->assertFalse( + $this->loadExtension()->getDefinition(BrowserFactory::class)->isPublic(), + ); + } + + /** + * @test + */ + public function load_artifact_sink_uses_behat_output(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(ArtifactSink::class); + + $this->assertSame(BehatOutputArtifactSink::class, $definition->getClass()); + } + + /** + * @test + */ + public function load_artifact_sink_is_private(): void + { + $this->assertFalse( + $this->loadExtension()->getDefinition(ArtifactSink::class)->isPublic(), + ); + } + + /** + * @test + */ + public function load_artifact_collector_references_registry_and_sink(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(ArtifactCollector::class); + + $this->assertSame(ArtifactCollector::class, $definition->getClass()); + $this->assertCount(2, $definition->getArguments()); + $this->assertReference(BrowserRegistry::class, $definition->getArgument(0)); + $this->assertReference(ArtifactSink::class, $definition->getArgument(1)); + } + + /** + * @test + */ + public function load_artifact_collector_is_private(): void + { + $this->assertFalse( + $this->loadExtension()->getDefinition(ArtifactCollector::class)->isPublic(), + ); + } + + /** + * @test + */ + public function load_context_initializer_references_factory_registry_and_booter(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(BrowserContextInitializer::class); + + $this->assertSame(BrowserContextInitializer::class, $definition->getClass()); + $this->assertCount(3, $definition->getArguments()); + $this->assertReference(BrowserFactory::class, $definition->getArgument(0)); + $this->assertReference(BrowserRegistry::class, $definition->getArgument(1)); + $this->assertReference(KernelBooter::class, $definition->getArgument(2)); + } + + /** + * @test + */ + public function load_tags_context_initializer(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(BrowserContextInitializer::class); + + $this->assertSame([ContextExtension::INITIALIZER_TAG => [[]]], $definition->getTags()); + } + + /** + * @test + */ + public function load_artifact_listener_references_collector_and_booter(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(ArtifactListener::class); + + $this->assertSame(ArtifactListener::class, $definition->getClass()); + $this->assertCount(2, $definition->getArguments()); + $this->assertReference(ArtifactCollector::class, $definition->getArgument(0)); + $this->assertReference(KernelBooter::class, $definition->getArgument(1)); + } + + /** + * @test + */ + public function load_tags_artifact_listener(): void + { + $container = $this->loadExtension(); + $definition = $container->getDefinition(ArtifactListener::class); + + $this->assertSame([EventDispatcherExtension::SUBSCRIBER_TAG => [[]]], $definition->getTags()); + } + + /** + * @test + * @runInSeparateProcess + */ + public function load_aliases_kernel_booter_to_symfony_extension_when_available(): void + { + \class_alias(FooBar::class, self::FOB_SYMFONY_EXTENSION); + + $container = $this->loadExtension(); + + $this->assertTrue($container->hasAlias(KernelBooter::class)); + $this->assertSame( + SymfonyExtensionKernelBooter::class, + (string) $container->getAlias(KernelBooter::class), + ); + } + + /** + * @test + * @runInSeparateProcess + */ + public function load_registers_symfony_extension_kernel_booter_when_available(): void + { + \class_alias(FooBar::class, self::FOB_SYMFONY_EXTENSION); + + $container = $this->loadExtension(); + + $this->assertTrue($container->hasDefinition(SymfonyExtensionKernelBooter::class)); + + $definition = $container->getDefinition(SymfonyExtensionKernelBooter::class); + $this->assertSame(SymfonyExtensionKernelBooter::class, $definition->getClass()); + + $this->assertCount(1, $definition->getArguments()); + $this->assertReference('fob_symfony.kernel', $definition->getArgument(0)); + } + + /** + * @return string[] + */ + private function expectedServiceIds(): array + { + return [ + BrowserRegistry::class, + BrowserOptions::class, + BrowserFactory::class, + ArtifactSink::class, + ArtifactCollector::class, + BrowserContextInitializer::class, + ArtifactListener::class, + ]; + } + + private function assertReference(string $expected, mixed $actual): void + { + $this->assertInstanceOf(Reference::class, $actual); + $this->assertSame($expected, (string) $actual); + } + + /** + * @param array $overrides + */ + private function loadExtension(array $overrides = []): ContainerBuilder + { + $container = new ContainerBuilder(); + + (new BrowserExtension())->load($container, \array_merge([ + 'kernel_class' => null, + 'env' => 'test', + 'debug' => true, + 'kernel_browser_class' => null, + 'panther_browser_class' => null, + 'source_dir' => './var/browser/source', + 'source_debug' => false, + 'follow_redirects' => true, + 'catch_exceptions' => true, + 'screenshot_dir' => './var/browser/screenshots', + 'console_log_dir' => './var/browser/console-logs', + 'always_start_webserver' => false, + 'panther_browser' => null, + ], $overrides)); + + return $container; + } +} + +class FooBar +{ +} diff --git a/tests/EchoArtifactSinkTest.php b/tests/EchoArtifactSinkTest.php new file mode 100644 index 0000000..9256b14 --- /dev/null +++ b/tests/EchoArtifactSinkTest.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Zenstruck\Browser\Artifact\ArtifactSink; +use Zenstruck\Browser\Artifact\EchoArtifactSink; + +/** + * @author Hugo Hamon + */ +final class EchoArtifactSinkTest extends TestCase +{ + /** + * @test + */ + public function implements_artifact_sink(): void + { + $this->assertInstanceOf(ArtifactSink::class, new EchoArtifactSink()); + } + + /** + * @test + */ + public function writes_nothing_when_no_artifacts_saved(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([]); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + /** + * @test + */ + public function writes_summary_with_single_test_single_category_single_artifact(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'MyTest::test_method' => [ + 'html' => ['/tmp/test.html'], + ], + ]); + $output = ob_get_clean(); + + $this->assertStringContainsString('Saved Browser Artifacts', $output); + $this->assertStringContainsString('MyTest::test_method', $output); + $this->assertStringContainsString('html:', $output); + $this->assertStringContainsString('/tmp/test.html:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_categories(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'MyTest::test_method' => [ + 'html' => ['/tmp/test.html'], + 'pdf' => ['/tmp/report.pdf'], + ], + ]); + $output = ob_get_clean(); + + $this->assertStringContainsString('html:', $output); + $this->assertStringContainsString('pdf:', $output); + $this->assertStringContainsString('/tmp/test.html:', $output); + $this->assertStringContainsString('/tmp/report.pdf:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_artifacts_per_category(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'MyTest::test_method' => [ + 'html' => ['/tmp/test.html', '/tmp/test2.html'], + ], + ]); + $output = ob_get_clean(); + + $this->assertStringContainsString('/tmp/test.html:', $output); + $this->assertStringContainsString('/tmp/test2.html:', $output); + } + + /** + * @test + */ + public function writes_summary_with_multiple_tests(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'FirstTest::test_a' => [ + 'html' => ['/tmp/a.html'], + ], + 'SecondTest::test_b' => [ + 'html' => ['/tmp/b.html'], + ], + ]); + $output = ob_get_clean(); + + $this->assertStringContainsString('FirstTest::test_a', $output); + $this->assertStringContainsString('SecondTest::test_b', $output); + } + + /** + * @test + */ + public function writes_summary_with_empty_category_array(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'MyTest::test_method' => [ + 'html' => [], + ], + ]); + $output = ob_get_clean(); + + $this->assertStringContainsString('Saved Browser Artifacts', $output); + $this->assertStringContainsString('html:', $output); + } + + /** + * @test + */ + public function summary_formatting_is_correct(): void + { + ob_start(); + (new EchoArtifactSink())->writeSummary([ + 'Test::case' => [ + 'html' => ['/tmp/page.html'], + ], + ]); + $output = ob_get_clean(); + + $expected = "\n\nSaved Browser Artifacts:\n\n Test::case\n html:\n * /tmp/page.html:"; + $this->assertSame($expected, $output); + } +} diff --git a/tests/LegacyExtensionTest.php b/tests/LegacyExtensionTest.php new file mode 100644 index 0000000..90089f5 --- /dev/null +++ b/tests/LegacyExtensionTest.php @@ -0,0 +1,300 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Zenstruck\Browser; +use Zenstruck\Browser\BrowserRegistry; +use Zenstruck\Browser\Test\LegacyExtension; + +/** + * @author Hugo Hamon + */ +final class LegacyExtensionTest extends TestCase +{ + protected function setUp(): void + { + BrowserRegistry::resetDefault(); + } + + protected function tearDown(): void + { + BrowserRegistry::resetDefault(); + } + + /** + * @test + */ + public function execute_before_first_test_starts_registry(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + + $this->assertTrue(BrowserRegistry::default()->isStarted()); + } + + /** + * @test + */ + public function execute_before_test_clears_registry(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + LegacyExtension::registerBrowser($this->createMock(Browser::class)); + + $extension->executeBeforeTest('test_name'); + + $this->assertTrue(BrowserRegistry::default()->isEmpty()); + } + + /** + * @test + */ + public function execute_after_test_clears_registry(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->method('savedArtifacts')->willReturn([]); + LegacyExtension::registerBrowser($browser); + + $this->assertFalse(BrowserRegistry::default()->isEmpty()); + + $extension->executeAfterTest('test_name', 0.0); + + $this->assertTrue(BrowserRegistry::default()->isEmpty()); + } + + /** + * @test + */ + public function execute_after_test_collects_saved_artifacts(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->method('savedArtifacts')->willReturn([]); + LegacyExtension::registerBrowser($browser); + + // Should not throw + $extension->executeAfterTest('test_name', 0.0); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function execute_after_test_error_triggers_save_current_state(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestError('test_name', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_test_error_passes_error_filename(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ->with($this->stringStartsWith('error_')) + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestError('test_name', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_test_error_normalizes_test_name(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ->with('error_MyClass__test_method__0') + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestError('MyClass::test_method', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_test_failure_triggers_save_current_state(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestFailure('test_name', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_test_failure_passes_failure_filename(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ->with($this->stringStartsWith('failure_')) + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestFailure('test_name', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_test_failure_normalizes_test_name(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + $browser = $this->createMock(Browser::class); + $browser->expects($this->once()) + ->method('saveCurrentState') + ->with('failure_MyClass__test_method__0') + ; + LegacyExtension::registerBrowser($browser); + + $extension->executeAfterTestFailure('MyClass::test_method', 'message', 0.0); + } + + /** + * @test + */ + public function execute_after_last_test_stops_registry(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + + $this->assertTrue(BrowserRegistry::default()->isStarted()); + + $extension->executeAfterLastTest(); + + $this->assertFalse(BrowserRegistry::default()->isStarted()); + } + + /** + * @test + */ + public function register_browser_adds_to_default_registry(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + + $browser = $this->createMock(Browser::class); + LegacyExtension::registerBrowser($browser); + + $this->assertSame([$browser], BrowserRegistry::default()->all()); + } + + /** + * @test + */ + public function does_not_save_state_when_registry_is_empty_on_error(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + + // No browser registered, should not throw + $extension->executeAfterTestError('test_name', 'message', 0.0); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function does_not_save_state_when_registry_is_empty_on_failure(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + + // No browser registered, should not throw + $extension->executeAfterTestFailure('test_name', 'message', 0.0); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function does_not_throw_when_no_browsers_on_finish(): void + { + $extension = new LegacyExtension(); + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + + // No browser registered, should not throw + $extension->executeAfterTest('test_name', 0.0); + + $this->addToAssertionCount(1); + } + + /** + * @test + */ + public function full_lifecycle_does_not_throw(): void + { + $extension = new LegacyExtension(); + + $browser = $this->createMock(Browser::class); + $browser->method('savedArtifacts')->willReturn([]); + + $extension->executeBeforeFirstTest(); + $extension->executeBeforeTest('test_name'); + LegacyExtension::registerBrowser($browser); + $extension->executeAfterTest('test_name', 0.0); + $extension->executeAfterLastTest(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/PantherClientFactoryTest.php b/tests/PantherClientFactoryTest.php new file mode 100644 index 0000000..dbe7b8c --- /dev/null +++ b/tests/PantherClientFactoryTest.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Panther\Client; +use Symfony\Component\Panther\PantherTestCase; +use Zenstruck\Browser\Bridge\Behat\Kernel\PantherClientFactory; + +/** + * @author Hugo Hamon + */ +final class PantherClientFactoryTest extends TestCase +{ + /** + * @test + */ + public function is_final_class(): void + { + $reflection = new \ReflectionClass(PantherClientFactory::class); + + $this->assertTrue($reflection->isFinal()); + } + + /** + * @test + */ + public function extends_panther_test_case(): void + { + $this->assertTrue( + is_subclass_of(PantherClientFactory::class, PantherTestCase::class) + ); + } + + /** + * @test + */ + public function create_primary_is_public_static_method_with_client_return_type(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createPrimary'); + + $this->assertTrue($method->isPublic()); + $this->assertTrue($method->isStatic()); + $this->assertSame(Client::class, $method->getReturnType()->getName()); + } + + /** + * @test + */ + public function create_primary_is_declared_in_factory_not_inherited(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createPrimary'); + + $this->assertSame(PantherClientFactory::class, $method->getDeclaringClass()->getName()); + } + + /** + * @test + */ + public function create_primary_has_correct_parameters(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createPrimary'); + $params = $method->getParameters(); + + $this->assertCount(3, $params); + + $this->assertSame('options', $params[0]->getName()); + $this->assertSame('array', $params[0]->getType()->getName()); + $this->assertTrue($params[0]->isOptional()); + $this->assertSame([], $params[0]->getDefaultValue()); + + $this->assertSame('kernelOptions', $params[1]->getName()); + $this->assertSame('array', $params[1]->getType()->getName()); + $this->assertTrue($params[1]->isOptional()); + $this->assertSame([], $params[1]->getDefaultValue()); + + $this->assertSame('managerOptions', $params[2]->getName()); + $this->assertSame('array', $params[2]->getType()->getName()); + $this->assertTrue($params[2]->isOptional()); + $this->assertSame([], $params[2]->getDefaultValue()); + } + + /** + * @test + */ + public function create_primary_body_delegates_to_self_create_panther_client(): void + { + $body = $this->methodBody('createPrimary'); + + $this->assertStringContainsString('self::createPantherClient($options, $kernelOptions, $managerOptions)', $body); + } + + /** + * @test + */ + public function create_additional_is_public_static_method_with_client_return_type(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createAdditional'); + + $this->assertTrue($method->isPublic()); + $this->assertTrue($method->isStatic()); + $this->assertSame(Client::class, $method->getReturnType()->getName()); + } + + /** + * @test + */ + public function create_additional_is_declared_in_factory_not_inherited(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createAdditional'); + + $this->assertSame(PantherClientFactory::class, $method->getDeclaringClass()->getName()); + } + + /** + * @test + */ + public function create_additional_has_no_parameters(): void + { + $method = new \ReflectionMethod(PantherClientFactory::class, 'createAdditional'); + + $this->assertCount(0, $method->getParameters()); + } + + /** + * @test + */ + public function create_additional_body_delegates_to_self_create_additional_panther_client(): void + { + $body = $this->methodBody('createAdditional'); + + $this->assertStringContainsString('self::createAdditionalPantherClient()', $body); + } + + /** + * @test + */ + public function inherits_create_panther_client_as_protected_static_method(): void + { + $this->assertTrue( + method_exists(PantherClientFactory::class, 'createPantherClient') + ); + + $method = new \ReflectionMethod(PantherClientFactory::class, 'createPantherClient'); + + $this->assertTrue($method->isProtected()); + $this->assertTrue($method->isStatic()); + $this->assertSame(Client::class, $method->getReturnType()->getName()); + } + + /** + * @test + */ + public function inherits_create_additional_panther_client_as_protected_static_method(): void + { + $this->assertTrue( + method_exists(PantherClientFactory::class, 'createAdditionalPantherClient') + ); + + $method = new \ReflectionMethod(PantherClientFactory::class, 'createAdditionalPantherClient'); + + $this->assertTrue($method->isProtected()); + $this->assertTrue($method->isStatic()); + $this->assertSame(Client::class, $method->getReturnType()->getName()); + } + + /** + * @test + */ + public function create_primary_parameters_match_parent_signature(): void + { + $primaryParams = (new \ReflectionMethod(PantherClientFactory::class, 'createPrimary'))->getParameters(); + $parentParams = (new \ReflectionMethod(PantherTestCase::class, 'createPantherClient'))->getParameters(); + + $this->assertCount(\count($parentParams), $primaryParams); + + foreach ($primaryParams as $i => $param) { + $this->assertSame($parentParams[$i]->getName(), $param->getName(), \sprintf('Parameter %d name mismatch.', $i)); + $this->assertSame($parentParams[$i]->getType()->getName(), $param->getType()->getName(), \sprintf('Parameter %d type mismatch.', $i)); + $this->assertSame($parentParams[$i]->isOptional(), $param->isOptional(), \sprintf('Parameter %d optional mismatch.', $i)); + $this->assertSame($parentParams[$i]->getDefaultValue(), $param->getDefaultValue(), \sprintf('Parameter %d default value mismatch.', $i)); + } + } + + /** + * @test + */ + public function create_additional_parameters_match_parent_signature(): void + { + $additionalParams = (new \ReflectionMethod(PantherClientFactory::class, 'createAdditional'))->getParameters(); + $parentParams = (new \ReflectionMethod(PantherTestCase::class, 'createAdditionalPantherClient'))->getParameters(); + + $this->assertCount(\count($parentParams), $additionalParams); + } + + /** + * @test + */ + public function both_methods_return_same_type_as_parent(): void + { + $factoryClientMethod = new \ReflectionMethod(PantherClientFactory::class, 'createPrimary'); + $parentClientMethod = new \ReflectionMethod(PantherTestCase::class, 'createPantherClient'); + + $this->assertSame( + $parentClientMethod->getReturnType()->getName(), + $factoryClientMethod->getReturnType()->getName() + ); + + $factoryAdditionalMethod = new \ReflectionMethod(PantherClientFactory::class, 'createAdditional'); + $parentAdditionalMethod = new \ReflectionMethod(PantherTestCase::class, 'createAdditionalPantherClient'); + + $this->assertSame( + $parentAdditionalMethod->getReturnType()->getName(), + $factoryAdditionalMethod->getReturnType()->getName() + ); + } + + private function methodBody(string $methodName): string + { + $method = new \ReflectionMethod(PantherClientFactory::class, $methodName); + $filename = $method->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = \file($filename); + + return \implode('', \array_slice($lines, $startLine, $endLine - $startLine - 1)); + } +} diff --git a/tests/PhpUnitKernelBooterTest.php b/tests/PhpUnitKernelBooterTest.php new file mode 100644 index 0000000..cb9d21a --- /dev/null +++ b/tests/PhpUnitKernelBooterTest.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\Panther\Client as PantherClient; +use Symfony\Component\Panther\ProcessManager\BrowserManagerInterface; +use Zenstruck\Browser\KernelBooter; +use Zenstruck\Browser\Test\PhpUnitKernelBooter; + +/** + * @author Hugo Hamon + */ +final class PhpUnitKernelBooterTest extends TestCase +{ + /** + * @test + */ + public function implements_kernel_booter(): void + { + $this->assertInstanceOf(KernelBooter::class, new PhpUnitKernelBooter(fn() => $this->createMock(SymfonyKernelBrowser::class))); + } + + /** + * @test + */ + public function createKernelBrowserClient_delegates_to_closure(): void + { + $expected = $this->createMock(SymfonyKernelBrowser::class); + + $booter = new PhpUnitKernelBooter(fn() => $expected); + + $this->assertSame($expected, $booter->createKernelBrowserClient()); + } + + /** + * @test + */ + public function createKernelBrowserClient_forwards_options_and_server(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: function(array $options, array $server): SymfonyKernelBrowser { + $this->assertSame(['custom' => 'opt'], $options); + $this->assertSame(['REMOTE_ADDR' => '10.0.0.1'], $server); + + return $this->createMock(SymfonyKernelBrowser::class); + }, + ); + + $booter->createKernelBrowserClient(options: ['custom' => 'opt'], server: ['REMOTE_ADDR' => '10.0.0.1']); + } + + /** + * @test + */ + public function createKernelBrowserClient_uses_empty_defaults(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: function(array $options, array $server): SymfonyKernelBrowser { + $this->assertSame([], $options); + $this->assertSame([], $server); + + return $this->createMock(SymfonyKernelBrowser::class); + }, + ); + + $booter->createKernelBrowserClient(); + } + + /** + * @test + */ + public function createPantherClient_without_panther_closure_throws_logic_exception(): void + { + $booter = new PhpUnitKernelBooter(fn() => $this->createMock(SymfonyKernelBrowser::class)); + + $this->expectException(\LogicException::class); + + $booter->createPantherClient(); + } + + /** + * @test + */ + public function createPantherClient_first_call_creates_and_caches(): void + { + $expected = $this->createPantherClient(); + + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: fn() => $expected, + ); + + $this->assertSame($expected, $booter->createPantherClient()); + } + + /** + * @test + */ + public function createPantherClient_first_call_forwards_options(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: function(array $options, array $kernelOptions, array $managerOptions): PantherClient { + $this->assertSame(['browser' => 'chrome'], $options); + $this->assertSame(['kernel' => 'opt'], $kernelOptions); + $this->assertSame(['manager' => 'opt'], $managerOptions); + + return $this->createPantherClient(); + }, + ); + + $booter->createPantherClient( + options: ['browser' => 'chrome'], + kernelOptions: ['kernel' => 'opt'], + managerOptions: ['manager' => 'opt'], + ); + } + + /** + * @test + */ + public function createPantherClient_uses_additional_closure_for_second_call(): void + { + $firstClient = $this->createPantherClient(); + $secondClient = $this->createPantherClient(); + + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: fn() => $firstClient, + createAdditionalPantherClient: fn() => $secondClient, + ); + + $this->assertSame($firstClient, $booter->createPantherClient()); + $this->assertSame($secondClient, $booter->createPantherClient()); + } + + /** + * @test + */ + public function createPantherClient_second_call_without_additional_closure_throws_error(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: fn() => $this->createPantherClient(), + ); + + $booter->createPantherClient(); + + $this->expectException(\Error::class); + + $booter->createPantherClient(); + } + + /** + * @test + */ + public function supportsPanther_returns_false_without_panther_closure(): void + { + $booter = new PhpUnitKernelBooter(fn() => $this->createMock(SymfonyKernelBrowser::class)); + + $this->assertFalse($booter->supportsPanther()); + } + + /** + * @test + */ + public function supportsPanther_returns_true_with_panther_closure(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: fn() => $this->createPantherClient(), + ); + + $this->assertTrue($booter->supportsPanther()); + } + + /** + * @test + */ + public function reset_creates_new_primary_client_on_next_call(): void + { + $booter = new PhpUnitKernelBooter( + createKernelBrowserClient: fn() => $this->createMock(SymfonyKernelBrowser::class), + createPantherClient: fn() => $this->createPantherClient(), + ); + + $first = $booter->createPantherClient(); + $booter->reset(); + $second = $booter->createPantherClient(); + + $this->assertNotSame($first, $second); + } + + private function createPantherClient(): PantherClient + { + $client = (new \ReflectionClass(PantherClient::class))->newInstanceWithoutConstructor(); + + $managerProperty = new \ReflectionProperty(PantherClient::class, 'browserManager'); + $managerProperty->setValue($client, $this->createMock(BrowserManagerInterface::class)); + + return $client; + } +} diff --git a/tests/StandaloneKernelBooterTest.php b/tests/StandaloneKernelBooterTest.php new file mode 100644 index 0000000..cbca696 --- /dev/null +++ b/tests/StandaloneKernelBooterTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Browser\Bridge\Behat\Kernel\StandaloneKernelBooter; +use Zenstruck\Browser\KernelBooter; +use Zenstruck\Browser\Tests\Fixture\Kernel; + +/** + * @author Hugo Hamon + */ +final class StandaloneKernelBooterTest extends TestCase +{ + /** + * @test + */ + public function implements_kernel_booter(): void + { + $this->assertInstanceOf(KernelBooter::class, new StandaloneKernelBooter(kernelClass: Kernel::class)); + } + + /** + * @test + */ + public function constructor_uses_given_kernel_class(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $this->assertTrue($booter->supportsPanther()); + } + + /** + * @test + */ + public function constructor_uses_env_var_when_no_class_given(): void + { + $_SERVER['KERNEL_CLASS'] = Kernel::class; + + try { + $booter = new StandaloneKernelBooter(); + $this->assertTrue($booter->supportsPanther()); + } finally { + unset($_SERVER['KERNEL_CLASS']); + } + } + + /** + * @test + */ + public function constructor_throws_when_no_kernel_class(): void + { + unset($_SERVER['KERNEL_CLASS']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('kernel_class'); + + new StandaloneKernelBooter(); + } + + /** + * @test + */ + public function constructor_throws_when_class_does_not_implement_kernel_interface(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('KernelInterface'); + + new StandaloneKernelBooter(kernelClass: self::class); + } + + /** + * @test + */ + public function constructor_throws_when_class_does_not_exist(): void + { + $this->expectException(\RuntimeException::class); + + new StandaloneKernelBooter(kernelClass: 'NonExistentKernelClass'); + } + + /** + * @test + */ + public function createKernelBrowserClient_returns_client_from_booted_kernel(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $client = $booter->createKernelBrowserClient(); + + $this->assertInstanceOf(SymfonyKernelBrowser::class, $client); + } + + /** + * @test + */ + public function createKernelBrowserClient_sets_server_parameters(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $client = $booter->createKernelBrowserClient(server: ['REMOTE_ADDR' => '10.0.0.1']); + + $this->assertInstanceOf(SymfonyKernelBrowser::class, $client); + } + + /** + * @test + */ + public function createKernelBrowserClient_uses_cached_kernel_on_subsequent_calls(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $first = $booter->createKernelBrowserClient(); + $second = $booter->createKernelBrowserClient(); + + $this->assertInstanceOf(SymfonyKernelBrowser::class, $first); + $this->assertInstanceOf(SymfonyKernelBrowser::class, $second); + } + + /** + * @test + */ + public function supportsPanther_returns_true_when_panther_installed(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $this->assertTrue($booter->supportsPanther()); + } + + /** + * @test + */ + public function reset_shuts_down_kernel(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + $booter->createKernelBrowserClient(); + + $booter->reset(); + + // After reset, the next call should create a new client + $client = $booter->createKernelBrowserClient(); + $this->assertInstanceOf(SymfonyKernelBrowser::class, $client); + } + + /** + * @test + */ + public function reset_without_booted_kernel_does_not_fail(): void + { + $booter = new StandaloneKernelBooter(kernelClass: Kernel::class); + + $booter->reset(); + + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/SymfonyExtensionKernelBooterTest.php b/tests/SymfonyExtensionKernelBooterTest.php new file mode 100644 index 0000000..e8f8e3d --- /dev/null +++ b/tests/SymfonyExtensionKernelBooterTest.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Browser\Tests; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\KernelBrowser as SymfonyKernelBrowser; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Browser\Bridge\Behat\Kernel\SymfonyExtensionKernelBooter; +use Zenstruck\Browser\KernelBooter; + +/** + * @author Hugo Hamon + */ +final class SymfonyExtensionKernelBooterTest extends TestCase +{ + private KernelInterface&MockObject $kernel; + private SymfonyExtensionKernelBooter $booter; + + protected function setUp(): void + { + $this->kernel = $this->createMock(KernelInterface::class); + $this->booter = new SymfonyExtensionKernelBooter($this->kernel); + } + + /** + * @test + */ + public function implements_kernel_booter(): void + { + $this->assertInstanceOf(KernelBooter::class, $this->booter); + } + + /** + * @test + */ + public function createKernelBrowserClient_returns_client_from_container(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + + $container = $this->createContainerWithTestClient($client); + $this->kernel + ->expects($this->exactly(2)) + ->method('getContainer') + ->willReturn($container) + ; + $this->kernel->expects($this->never())->method('boot'); + + $this->assertSame($client, $this->booter->createKernelBrowserClient()); + } + + /** + * @test + */ + public function createKernelBrowserClient_boots_kernel_when_container_lacks_client(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + + $firstContainer = $this->createContainerWithoutTestClient(); + $secondContainer = $this->createContainerWithTestClient($client); + + $this->kernel + ->expects($this->exactly(2)) + ->method('getContainer') + ->willReturnOnConsecutiveCalls($firstContainer, $secondContainer) + ; + $this->kernel->expects($this->once())->method('boot'); + + $this->assertSame($client, $this->booter->createKernelBrowserClient()); + } + + /** + * @test + */ + public function createKernelBrowserClient_sets_server_parameters(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $client->expects($this->once()) + ->method('setServerParameters') + ->with($this->identicalTo(['REMOTE_ADDR' => '10.0.0.1'])) + ; + + $container = $this->createContainerWithTestClient($client); + $this->kernel->method('getContainer')->willReturn($container); + + $this->booter->createKernelBrowserClient(server: ['REMOTE_ADDR' => '10.0.0.1']); + } + + /** + * @test + */ + public function createKernelBrowserClient_uses_empty_server_parameters_by_default(): void + { + $client = $this->createMock(SymfonyKernelBrowser::class); + $client->expects($this->once()) + ->method('setServerParameters') + ->with($this->identicalTo([])) + ; + + $container = $this->createContainerWithTestClient($client); + $this->kernel->method('getContainer')->willReturn($container); + + $this->booter->createKernelBrowserClient(); + } + + /** + * @test + */ + public function createKernelBrowserClient_throws_when_test_client_not_enabled(): void + { + $container = $this->createContainerWithoutTestClient(); + + $this->kernel + ->expects($this->exactly(2)) + ->method('getContainer') + ->willReturnOnConsecutiveCalls($container, $container) + ; + $this->kernel->expects($this->once())->method('boot'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The Symfony test client is not enabled'); + + $this->booter->createKernelBrowserClient(); + } + + /** + * @test + */ + public function supportsPanther_returns_true_when_panther_installed(): void + { + $this->assertTrue($this->booter->supportsPanther()); + } + + /** + * @test + */ + public function reset_shuts_down_kernel(): void + { + $this->kernel->expects($this->once())->method('shutdown'); + + $this->booter->reset(); + } + + /** + * @return ContainerInterface&MockObject + */ + private function createContainerWithTestClient(SymfonyKernelBrowser $client): MockObject + { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('test.client')->willReturn(true); + $container->method('get')->with('test.client')->willReturn($client); + + return $container; + } + + /** + * @return ContainerInterface&MockObject + */ + private function createContainerWithoutTestClient(): MockObject + { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('test.client')->willReturn(false); + + return $container; + } +}