diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index db3651cb..f7f897c8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - php: [8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3, 8.4] symfony: ["5.4.*", "6.4.*", "7.1.*"] exclude: - php: 8.1 diff --git a/composer.json b/composer.json index 684c23c1..65238b1d 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "type": "library", "keywords": [ "codeception", + "functional testing", "symfony" ], "authors": [ @@ -37,10 +38,12 @@ "symfony/filesystem": "^5.4 | ^6.4 | ^7.0", "symfony/form": "^5.4 | ^6.4 | ^7.0", "symfony/framework-bundle": "^5.4 | ^6.4 | ^7.0", + "symfony/http-client": "^5.4 | ^6.4 | ^7.0", "symfony/http-foundation": "^5.4 | ^6.4 | ^7.0", "symfony/http-kernel": "^5.4 | ^6.4 | ^7.0", "symfony/mailer": "^5.4 | ^6.4 | ^7.0", "symfony/mime": "^5.4 | ^6.4 | ^7.0", + "symfony/notifier": "^5.4 | ^6.4 | ^7.0", "symfony/options-resolver": "^5.4 | ^6.4 | ^7.0", "symfony/property-access": "^5.4 | ^6.4 | ^7.0", "symfony/property-info": "^5.4 | ^6.4 | ^7.0", diff --git a/readme.md b/readme.md index 06d2d614..ccedd850 100644 --- a/readme.md +++ b/readme.md @@ -9,7 +9,7 @@ A Codeception module for Symfony framework. ## Requirements -* `Symfony` `5.4.x`, `6.4.x`, `7.0.x` or higher, as per the [Symfony supported versions](https://symfony.com/releases). +* `Symfony` `5.4.x`, `6.4.x`, `7.1.x` or higher, as per the [Symfony supported versions](https://symfony.com/releases). * `PHP 8.1` or higher. ## Installation diff --git a/src/Codeception/Lib/Connector/Symfony.php b/src/Codeception/Lib/Connector/Symfony.php index 684add44..44d7595a 100644 --- a/src/Codeception/Lib/Connector/Symfony.php +++ b/src/Codeception/Lib/Connector/Symfony.php @@ -21,15 +21,8 @@ class Symfony extends HttpKernelBrowser { private bool $hasPerformedRequest = false; - private ?ContainerInterface $container; - /** - * Constructor. - * - * @param Kernel $kernel A booted HttpKernel instance - * @param array $persistentServices An injected services - */ public function __construct( Kernel $kernel, public array $persistentServices = [], @@ -74,14 +67,12 @@ public function rebootKernel(): void $this->persistDoctrineConnections(); $this->kernel->reboot(null); - $this->container = $this->getContainer(); foreach ($this->persistentServices as $serviceName => $service) { try { $this->container->set($serviceName, $service); } catch (InvalidArgumentException $e) { - //Private services can't be set in Symfony 4 codecept_debug("[Symfony] Can't set persistent service {$serviceName}: " . $e->getMessage()); } } @@ -95,31 +86,23 @@ private function getContainer(): ?ContainerInterface { /** @var ContainerInterface $container */ $container = $this->kernel->getContainer(); - if ($container->has('test.service_container')) { - $container = $container->get('test.service_container'); - } - - return $container; + return $container->has('test.service_container') + ? $container->get('test.service_container') + : $container; } private function getProfiler(): ?Profiler { - if ($this->container->has('profiler')) { - /** @var Profiler $profiler */ - $profiler = $this->container->get('profiler'); - return $profiler; - } - - return null; + return $this->container->has('profiler') + ? $this->container->get('profiler') + : null; } private function getService(string $serviceName): ?object { - if ($this->container->has($serviceName)) { - return $this->container->get($serviceName); - } - - return null; + return $this->container->has($serviceName) + ? $this->container->get($serviceName) + : null; } private function persistDoctrineConnections(): void diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 6323e523..3f7917bb 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -13,8 +13,10 @@ use Codeception\Module\Symfony\BrowserAssertionsTrait; use Codeception\Module\Symfony\ConsoleAssertionsTrait; use Codeception\Module\Symfony\DoctrineAssertionsTrait; +use Codeception\Module\Symfony\DomCrawlerAssertionsTrait; use Codeception\Module\Symfony\EventsAssertionsTrait; use Codeception\Module\Symfony\FormAssertionsTrait; +use Codeception\Module\Symfony\HttpClientAssertionsTrait; use Codeception\Module\Symfony\MailerAssertionsTrait; use Codeception\Module\Symfony\MimeAssertionsTrait; use Codeception\Module\Symfony\ParameterAssertionsTrait; @@ -42,11 +44,9 @@ use Symfony\Component\HttpKernel\Profiler\Profile; use Symfony\Component\HttpKernel\Profiler\Profiler; use Symfony\Component\Mailer\DataCollector\MessageDataCollector; -use Symfony\Component\Routing\Route; use Symfony\Component\VarDumper\Cloner\Data; use function array_keys; use function array_map; -use function array_search; use function array_unique; use function class_exists; use function codecept_root_dir; @@ -55,7 +55,6 @@ use function implode; use function ini_get; use function ini_set; -use function is_null; use function iterator_to_array; use function number_format; use function sprintf; @@ -83,7 +82,7 @@ * * `kernel_class`: 'App\Kernel' - Kernel class name * * `em_service`: 'doctrine.orm.entity_manager' - Use the stated EntityManager to pair with Doctrine Module. * * `debug`: true - Turn on/off [debug mode](https://codeception.com/docs/Debugging) - * * `cache_router`: 'false' - Enable router caching between tests in order to [increase performance](http://lakion.com/blog/how-did-we-speed-up-sylius-behat-suite-with-blackfire) + * * `cache_router`: 'false' - Enable router caching between tests in order to [increase performance](http://lakion.com/blog/how-did-we-speed-up-sylius-behat-suite-with-blackfire) (can have an impact on ajax requests sending via '$I->sendAjaxPostRequest()') * * `rebootable_client`: 'true' - Reboot client's kernel before each request * * `guard`: 'false' - Enable custom authentication system with guard (only for Symfony 5.4) * * `bootstrap`: 'false' - Enable the test environment setup with the tests/bootstrap.php file if it exists or with Symfony DotEnv otherwise. If false, it does nothing. @@ -138,8 +137,10 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule use BrowserAssertionsTrait; use ConsoleAssertionsTrait; use DoctrineAssertionsTrait; + use DomCrawlerAssertionsTrait; use EventsAssertionsTrait; use FormAssertionsTrait; + use HttpClientAssertionsTrait; use MailerAssertionsTrait; use MimeAssertionsTrait; use ParameterAssertionsTrait; @@ -174,46 +175,34 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule 'guard' => false ]; - /** - * @return string[] - */ - public function _parts(): array - { - return ['services']; - } - protected ?string $kernelClass = null; - /** * Services that should be persistent permanently for all tests - * - * @var array */ - protected $permanentServices = []; - + protected array $permanentServices = []; /** * Services that should be persistent during test execution between kernel reboots - * - * @var array */ - protected $persistentServices = []; + protected array $persistentServices = []; + + /** + * @return string[] + */ + public function _parts(): array + { + return ['services']; + } public function _initialize(): void { $this->kernelClass = $this->getKernelClass(); - $maxNestingLevel = 200; // Symfony may have very long nesting level - $xdebugMaxLevelKey = 'xdebug.max_nesting_level'; - if (ini_get($xdebugMaxLevelKey) < $maxNestingLevel) { - ini_set($xdebugMaxLevelKey, (string)$maxNestingLevel); - } - + $this->setXdebugMaxNestingLevel(200); $this->kernel = new $this->kernelClass($this->config['environment'], $this->config['debug']); - if($this->config['bootstrap']) { + if ($this->config['bootstrap']) { $this->bootstrapEnvironment(); } $this->kernel->boot(); - - if ($this->config['cache_router'] === true) { + if ($this->config['cache_router']) { $this->persistPermanentService('router'); } } @@ -223,7 +212,7 @@ public function _initialize(): void */ public function _before(TestInterface $test): void { - $this->persistentServices = [...$this->persistentServices, ...$this->permanentServices]; + $this->persistentServices = array_merge($this->persistentServices, $this->permanentServices); $this->client = new SymfonyConnector($this->kernel, $this->persistentServices, $this->config['rebootable_client']); } @@ -235,7 +224,6 @@ public function _after(TestInterface $test): void foreach (array_keys($this->permanentServices) as $serviceName) { $this->permanentServices[$serviceName] = $this->grabService($serviceName); } - parent::_after($test); } @@ -258,40 +246,24 @@ public function _getEntityManager(): EntityManagerInterface $emService = $this->config['em_service']; if (!isset($this->permanentServices[$emService])) { - // Try to persist configured entity manager $this->persistPermanentService($emService); $container = $this->_getContainer(); - if ($container->has('doctrine')) { - $this->persistPermanentService('doctrine'); - } - - if ($container->has('doctrine.orm.default_entity_manager')) { - $this->persistPermanentService('doctrine.orm.default_entity_manager'); - } - - if ($container->has('doctrine.dbal.default_connection')) { - $this->persistPermanentService('doctrine.dbal.default_connection'); + $services = ['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection']; + foreach ($services as $service) { + if ($container->has($service)) { + $this->persistPermanentService($service); + } } } return $this->permanentServices[$emService]; } - /** - * Return container. - */ public function _getContainer(): ContainerInterface { $container = $this->kernel->getContainer(); - if (!$container instanceof ContainerInterface) { - $this->fail('Could not get Symfony container'); - } - if ($container->has('test.service_container')) { - $container = $container->get('test.service_container'); - } - - return $container; + return $container->has('test.service_container') ? $container->get('test.service_container') : $container; } protected function getClient(): SymfonyConnector @@ -317,9 +289,10 @@ protected function getKernelClass(): string ); } + $this->requireAdditionalAutoloader(); + $finder = new Finder(); - $finder->name('*Kernel.php')->depth('0')->in($path); - $results = iterator_to_array($finder); + $results = iterator_to_array($finder->name('*Kernel.php')->depth('0')->in($path)); if ($results === []) { throw new ModuleRequireException( self::class, @@ -328,22 +301,17 @@ protected function getKernelClass(): string ); } - $this->requireAdditionalAutoloader(); - + $kernelClass = $this->config['kernel_class']; $filesRealPath = array_map(static function ($file) { require_once $file; return $file->getRealPath(); }, $results); - $kernelClass = $this->config['kernel_class']; - if (class_exists($kernelClass)) { $reflectionClass = new ReflectionClass($kernelClass); - if ($file = array_search($reflectionClass->getFileName(), $filesRealPath, true)) { + if (in_array($reflectionClass->getFileName(), $filesRealPath, true)) { return $kernelClass; } - - throw new ModuleRequireException(self::class, "Kernel class was not found in {$file}."); } throw new ModuleRequireException( @@ -356,13 +324,9 @@ protected function getKernelClass(): string protected function getProfile(): ?Profile { /** @var Profiler $profiler */ - if (!$profiler = $this->getService('profiler')) { - return null; - } - + $profiler = $this->getService('profiler'); try { - $response = $this->getClient()->getResponse(); - return $profiler->loadProfileFromResponse($response); + return $profiler?->loadProfileFromResponse($this->getClient()->getResponse()); } catch (BadMethodCallException) { $this->fail('You must perform a request before using this method.'); } catch (Exception $e) { @@ -375,22 +339,14 @@ protected function getProfile(): ?Profile /** * Grabs a Symfony Data Collector */ - protected function grabCollector(string $collector, string $function, string $message = null): DataCollectorInterface + protected function grabCollector(string $collector, string $function, ?string $message = null): DataCollectorInterface { - if (($profile = $this->getProfile()) === null) { - $this->fail( - sprintf("The Profile is needed to use the '%s' function.", $function) - ); + $profile = $this->getProfile(); + if ($profile === null) { + $this->fail(sprintf("The Profile is needed to use the '%s' function.", $function)); } - if (!$profile->hasCollector($collector)) { - if ($message) { - $this->fail($message); - } - - $this->fail( - sprintf("The '%s' collector is needed to use the '%s' function.", $collector, $function) - ); + $this->fail($message ?: "The '{$collector}' collector is needed to use the '{$function}' function."); } return $profile->getCollector($collector); @@ -399,49 +355,23 @@ protected function grabCollector(string $collector, string $function, string $me /** * Set the data that will be displayed when running a test with the `--debug` flag * - * @param $url + * @param mixed $url */ protected function debugResponse($url): void { parent::debugResponse($url); - - if (($profile = $this->getProfile()) === null) { - return; - } - - if ($profile->hasCollector('security')) { - /** @var SecurityDataCollector $security */ - $security = $profile->getCollector('security'); - if ($security->isAuthenticated()) { - $roles = $security->getRoles(); - - if ($roles instanceof Data) { - $roles = $roles->getValue(); + if ($profile = $this->getProfile()) { + $collectors = [ + 'security' => 'debugSecurityData', + 'mailer' => 'debugMailerData', + 'time' => 'debugTimeData', + ]; + foreach ($collectors as $collector => $method) { + if ($profile->hasCollector($collector)) { + $this->$method($profile->getCollector($collector)); } - - $this->debugSection( - 'User', - $security->getUser() - . ' [' . implode(',', $roles) . ']' - ); - } else { - $this->debugSection('User', 'Anonymous'); } } - - if ($profile->hasCollector('mailer')) { - /** @var MessageDataCollector $mailerCollector */ - $mailerCollector = $profile->getCollector('mailer'); - $emails = count($mailerCollector->getEvents()->getMessages()); - $this->debugSection('Emails', $emails . ' sent'); - } - - if ($profile->hasCollector('time')) { - /** @var TimeDataCollector $timeCollector */ - $timeCollector = $profile->getCollector('time'); - $duration = number_format($timeCollector->getDuration(), 2) . ' ms'; - $this->debugSection('Time', $duration); - } } /** @@ -450,15 +380,14 @@ protected function debugResponse($url): void protected function getInternalDomains(): array { $internalDomains = []; - $router = $this->grabRouterService(); $routes = $router->getRouteCollection(); - /* @var Route $route */ + foreach ($routes as $route) { - if (!is_null($route->getHost())) { - $compiled = $route->compile(); - if (!is_null($compiled->getHostRegex())) { - $internalDomains[] = $compiled->getHostRegex(); + if ($route->getHost() !== null) { + $compiledRoute = $route->compile(); + if ($compiledRoute->getHostRegex() !== null) { + $internalDomains[] = $compiledRoute->getHostRegex(); } } } @@ -466,10 +395,16 @@ protected function getInternalDomains(): array return array_unique($internalDomains); } + private function setXdebugMaxNestingLevel(int $maxNestingLevel): void + { + if (ini_get('xdebug.max_nesting_level') < $maxNestingLevel) { + ini_set('xdebug.max_nesting_level', (string)$maxNestingLevel); + } + } + private function bootstrapEnvironment(): void { $bootstrapFile = $this->kernel->getProjectDir() . '/tests/bootstrap.php'; - if (file_exists($bootstrapFile)) { require_once $bootstrapFile; } else { @@ -486,6 +421,28 @@ private function bootstrapEnvironment(): void } } + private function debugSecurityData(SecurityDataCollector $security): void + { + if ($security->isAuthenticated()) { + $roles = $security->getRoles(); + $rolesString = implode(',', $roles instanceof Data ? $roles->getValue() : $roles); + $userInfo = $security->getUser() . ' [' . $rolesString . ']'; + } else { + $userInfo = 'Anonymous'; + } + $this->debugSection('User', $userInfo); + } + + private function debugMailerData(MessageDataCollector $mailerCollector): void + { + $this->debugSection('Emails', count($mailerCollector->getEvents()->getMessages()) . ' sent'); + } + + private function debugTimeData(TimeDataCollector $timeCollector): void + { + $this->debugSection('Time', number_format($timeCollector->getDuration(), 2) . ' ms'); + } + /** * Ensures autoloader loading of additional directories. * It is only required for CI jobs to run correctly. diff --git a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php index 001e7ca2..488e1976 100644 --- a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php @@ -4,14 +4,188 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\BrowserKit\Test\Constraint\BrowserCookieValueSame; +use Symfony\Component\BrowserKit\Test\Constraint\BrowserHasCookie; +use Symfony\Component\HttpFoundation\Test\Constraint\RequestAttributeValueSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseCookieValueSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseFormatSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasCookie; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasHeader; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderLocationSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsRedirected; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsSuccessful; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsUnprocessable; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseStatusCodeSame; use function sprintf; trait BrowserAssertionsTrait { /** - * Reboot client's kernel. - * Can be used to manually reboot kernel when 'rebootable_client' => false + * Asserts that the given cookie in the test client is set to the expected value. + */ + public function assertBrowserCookieValueSame(string $name, string $expectedValue, bool $raw = false, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); + $this->assertThatForClient(new BrowserCookieValueSame($name, $expectedValue, $raw, $path, $domain), $message); + } + + /** + * Asserts that the test client has the specified cookie set. + * This indicates that the cookie was set by any response during the test. + */ + public function assertBrowserHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); + } + + /** + * Asserts that the test client does not have the specified cookie set. + * This indicates that the cookie was not set by any response during the test. + */ + public function assertBrowserNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(new LogicalNot(new BrowserHasCookie($name, $path, $domain)), $message); + } + + /** + * Asserts that the specified request attribute matches the expected value. + */ + public function assertRequestAttributeValueSame(string $name, string $expectedValue, string $message = ''): void + { + $this->assertThat($this->getClient()->getRequest(), new RequestAttributeValueSame($name, $expectedValue), $message); + } + + /** + * Asserts that the specified response cookie is present and matches the expected value. + */ + public function assertResponseCookieValueSame(string $name, string $expectedValue, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); + $this->assertThatForResponse(new ResponseCookieValueSame($name, $expectedValue, $path, $domain), $message); + } + + /** + * Asserts that the response format matches the expected format. This checks the format returned by the `Response::getFormat()` method. + */ + public function assertResponseFormatSame(?string $expectedFormat, string $message = ''): void + { + $this->assertThatForResponse(new ResponseFormatSame($this->getClient()->getRequest(), $expectedFormat), $message); + } + + /** + * Asserts that the specified cookie is present in the response. Optionally, it can check for a specific cookie path or domain. + */ + public function assertResponseHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); + } + + /** + * Asserts that the specified header is available in the response. + * For example, use `assertResponseHasHeader('content-type');`. + */ + public function assertResponseHasHeader(string $headerName, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHasHeader($headerName), $message); + } + + /** + * Asserts that the specified header does not contain the expected value in the response. + * For example, use `assertResponseHeaderNotSame('content-type', 'application/octet-stream');`. + */ + public function assertResponseHeaderNotSame(string $headerName, string $expectedValue, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHeaderSame($headerName, $expectedValue)), $message); + } + + /** + * Asserts that the specified header contains the expected value in the response. + * For example, use `assertResponseHeaderSame('content-type', 'application/octet-stream');`. + */ + public function assertResponseHeaderSame(string $headerName, string $expectedValue, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHeaderSame($headerName, $expectedValue), $message); + } + + /** + * Asserts that the response was successful (HTTP status code is in the 2xx range). + */ + public function assertResponseIsSuccessful(string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseIsSuccessful($verbose), $message); + } + + /** + * Asserts that the response is unprocessable (HTTP status code is 422). + */ + public function assertResponseIsUnprocessable(string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseIsUnprocessable($verbose), $message); + } + + /** + * Asserts that the specified cookie is not present in the response. Optionally, it can check for a specific cookie path or domain. + */ + public function assertResponseNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHasCookie($name, $path, $domain)), $message); + } + + /** + * Asserts that the specified header is not available in the response. + * For example, use `assertResponseNotHasHeader('content-type');`. + */ + public function assertResponseNotHasHeader(string $headerName, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHasHeader($headerName)), $message); + } + + /** + * Asserts that the response is a redirect. Optionally, you can check the target location and status code. + * The expected location can be either an absolute or a relative path. + */ + public function assertResponseRedirects(?string $expectedLocation = null, ?int $expectedCode = null, string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseIsRedirected($verbose), $message); + + if ($expectedLocation) { + $constraint = class_exists(ResponseHeaderLocationSame::class) + ? new ResponseHeaderLocationSame($this->getClient()->getRequest(), $expectedLocation) + : new ResponseHeaderSame('Location', $expectedLocation); + $this->assertThatForResponse($constraint, $message); + } + + if ($expectedCode) { + $this->assertThatForResponse(new ResponseStatusCodeSame($expectedCode), $message); + } + } + + /** + * Asserts that the response status code matches the expected code. + */ + public function assertResponseStatusCodeSame(int $expectedCode, string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseStatusCodeSame($expectedCode, $verbose), $message); + } + + /** + * Asserts the request matches the given route and optionally route parameters. + */ + public function assertRouteSame(string $expectedRoute, array $parameters = [], string $message = ''): void { + $request = $this->getClient()->getRequest(); + $this->assertThat($request, new RequestAttributeValueSame('_route', $expectedRoute)); + + foreach ($parameters as $key => $value) { + $this->assertThat($request, new RequestAttributeValueSame($key, $value), $message); + } + } + + /** + * Reboots the client's kernel. + * Can be used to manually reboot the kernel when 'rebootable_client' is set to false. * * ```php * seePageIsAvailable('/dashboard'); // Same as above * ``` * - * @param string|null $url + * @param string|null $url The URL of the page to check. If null, the current page is checked. */ - public function seePageIsAvailable(string $url = null): void + public function seePageIsAvailable(?string $url = null): void { if ($url !== null) { $this->amOnPage($url); $this->seeInCurrentUrl($url); } - $this->assertThat($this->getClient()->getResponse(), new ResponseIsSuccessful()); + $this->assertResponseIsSuccessful(); } /** - * Goes to a page and check that it redirects to another. + * Navigates to a page and verifies that it redirects to another page. * * ```php * getClient()->followRedirects(false); + $client = $this->getClient(); + $client->followRedirects(false); $this->amOnPage($page); - $response = $this->getClient()->getResponse(); + $this->assertTrue( - $response->isRedirection() + $client->getResponse()->isRedirection(), + 'The response is not a redirection.' ); - $this->getClient()->followRedirect(); + + $client->followRedirect(); $this->seeInCurrentUrl($redirectsTo); } /** - * Submit a form specifying the form name only once. + * Submits a form by specifying the form name only once. * * Use this function instead of [`$I->submitForm()`](#submitForm) to avoid repeating the form name in the field selectors. - * If you customized the names of the field selectors use `$I->submitForm()` for full control. + * If you have customized the names of the field selectors, use `$I->submitForm()` for full control. * * ```php * ` (you cannot use an array as selector here) - * @param string[] $fields + * @param string $name The `name` attribute of the `
`. You cannot use an array as a selector here. + * @param array $fields The form fields to submit. */ public function submitSymfonyForm(string $name, array $fields): void { @@ -104,4 +281,14 @@ public function submitSymfonyForm(string $name, array $fields): void $this->submitForm($selector, $params, $button); } + + protected function assertThatForClient(Constraint $constraint, string $message = ''): void + { + $this->assertThat($this->getClient(), $constraint, $message); + } + + protected function assertThatForResponse(Constraint $constraint, string $message = ''): void + { + $this->assertThat($this->getClient()->getResponse(), $constraint, $message); + } } diff --git a/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php new file mode 100644 index 00000000..643e3fd1 --- /dev/null +++ b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php @@ -0,0 +1,120 @@ +assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked"), $message); + } + + /** + * Asserts that the checkbox with the given name is not checked. + */ + public function assertCheckboxNotChecked(string $fieldName, string $message = ''): void + { + $this->assertThatCrawler(new LogicalNot( + new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked") + ), $message); + } + + /** + * Asserts that the value of the form input with the given name does not equal the expected value. + */ + public function assertInputValueNotSame(string $fieldName, string $expectedValue, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); + $this->assertThatCrawler(new LogicalNot( + new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue) + ), $message); + } + + /** + * Asserts that the value of the form input with the given name equals the expected value. + */ + public function assertInputValueSame(string $fieldName, string $expectedValue, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); + $this->assertThatCrawler( + new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue), + $message + ); + } + + /** + * Asserts that the `` element contains the given title. + */ + public function assertPageTitleContains(string $expectedTitle, string $message = ''): void + { + $this->assertSelectorTextContains('title', $expectedTitle, $message); + } + + /** + * Asserts that the `<title>` element equals the given title. + */ + public function assertPageTitleSame(string $expectedTitle, string $message = ''): void + { + $this->assertSelectorTextSame('title', $expectedTitle, $message); + } + + /** + * Asserts that the given selector matches at least one element in the response. + */ + public function assertSelectorExists(string $selector, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + } + + /** + * Asserts that the given selector does not match at least one element in the response. + */ + public function assertSelectorNotExists(string $selector, string $message = ''): void + { + $this->assertThatCrawler(new LogicalNot(new CrawlerSelectorExists($selector)), $message); + } + + /** + * Asserts that the first element matching the given selector contains the expected text. + */ + public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertThatCrawler(new CrawlerSelectorTextContains($selector, $text), $message); + } + + /** + * Asserts that the first element matching the given selector does not contain the expected text. + */ + public function assertSelectorTextNotContains(string $selector, string $text, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertThatCrawler(new LogicalNot(new CrawlerSelectorTextContains($selector, $text)), $message); + } + + /** + * Asserts that the text of the first element matching the given selector equals the expected text. + */ + public function assertSelectorTextSame(string $selector, string $text, string $message = ''): void + { + $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertThatCrawler(new CrawlerSelectorTextSame($selector, $text), $message); + } + + protected function assertThatCrawler(Constraint $constraint, string $message): void + { + $this->assertThat($this->getClient()->getCrawler(), $constraint, $message); + } +} diff --git a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php index 8ee296b9..f761499b 100644 --- a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php @@ -23,7 +23,7 @@ trait EventsAssertionsTrait * * @param string|string[]|null $expected */ - public function dontSeeEvent(array|string $expected = null): void + public function dontSeeEvent(array|string|null $expected = null): void { $actualEvents = [...array_column($this->getCalledListeners(), 'event')]; $actual = [$this->getOrphanedEvents(), $actualEvents]; @@ -87,7 +87,7 @@ public function dontSeeEventTriggered(array|object|string $expected): void * * @param string|string[] $expected */ - public function dontSeeOrphanEvent(array|string $expected = null): void + public function dontSeeOrphanEvent(array|string|null $expected = null): void { $actual = [$this->getOrphanedEvents()]; $this->assertEventTriggered(false, $expected, $actual); diff --git a/src/Codeception/Module/Symfony/FormAssertionsTrait.php b/src/Codeception/Module/Symfony/FormAssertionsTrait.php index c6fba53d..cdebbb64 100644 --- a/src/Codeception/Module/Symfony/FormAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/FormAssertionsTrait.php @@ -12,6 +12,29 @@ trait FormAssertionsTrait { + /** + * Asserts that value of the field of the first form matching the given selector does equal the expected value. + */ + public function assertFormValue(string $formSelector, string $fieldName, string $value, string $message = ''): void + { + $node = $this->getCLient()->getCrawler()->filter($formSelector); + $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); + $this->assertArrayHasKey($fieldName, $values, $message ?: sprintf('Field "%s" not found in form "%s".', $fieldName, $formSelector)); + $this->assertSame($value, $values[$fieldName]); + } + + /** + * Asserts that value of the field of the first form matching the given selector does equal the expected value. + */ + public function assertNoFormValue(string $formSelector, string $fieldName, string $message = ''): void + { + $node = $this->getCLient()->getCrawler()->filter($formSelector); + $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); + $this->assertArrayNotHasKey($fieldName, $values, $message ?: sprintf('Field "%s" has a value in form "%s".', $fieldName, $formSelector)); + } + /** * Verifies that there are no errors bound to the submitted form. * @@ -42,10 +65,8 @@ public function dontSeeFormErrors(): void * $I->seeFormErrorMessage('username'); * $I->seeFormErrorMessage('username', 'Username is empty'); * ``` - * - * @param string|null $message */ - public function seeFormErrorMessage(string $field, string $message = null): void + public function seeFormErrorMessage(string $field, ?string $message = null): void { $formCollector = $this->grabFormCollector(__FUNCTION__); diff --git a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php new file mode 100644 index 00000000..9ac3a6e4 --- /dev/null +++ b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Codeception\Module\Symfony; + +use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; +use function array_key_exists; +use function is_string; + +trait HttpClientAssertionsTrait +{ + /** + * Asserts that the given URL has been called using, if specified, the given method body and headers. + * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. (It will succeed if the request has been called multiple times.) + */ + public function assertHttpClientRequest(string $expectedUrl, string $expectedMethod = 'GET', string|array|null $expectedBody = null, array $expectedHeaders = [], string $httpClientId = 'http_client'): void + { + $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); + $expectedRequestHasBeenFound = false; + + if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { + $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); + } + + foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { + if (($expectedUrl !== $trace['info']['url'] && $expectedUrl !== $trace['url']) + || $expectedMethod !== $trace['method'] + ) { + continue; + } + + if (null !== $expectedBody) { + $actualBody = null; + + if (null !== $trace['options']['body'] && null === $trace['options']['json']) { + $actualBody = is_string($trace['options']['body']) ? $trace['options']['body'] : $trace['options']['body']->getValue(true); + } + + if (null === $trace['options']['body'] && null !== $trace['options']['json']) { + $actualBody = $trace['options']['json']->getValue(true); + } + + if (!$actualBody) { + continue; + } + + if ($expectedBody === $actualBody) { + $expectedRequestHasBeenFound = true; + + if (!$expectedHeaders) { + break; + } + } + } + + if ($expectedHeaders) { + $actualHeaders = $trace['options']['headers'] ?? []; + + foreach ($actualHeaders as $headerKey => $actualHeader) { + if (array_key_exists($headerKey, $expectedHeaders) + && $expectedHeaders[$headerKey] === $actualHeader->getValue(true) + ) { + $expectedRequestHasBeenFound = true; + break 2; + } + } + } + + $expectedRequestHasBeenFound = true; + break; + } + + $this->assertTrue($expectedRequestHasBeenFound, 'The expected request has not been called: "' . $expectedMethod . '" - "' . $expectedUrl . '"'); + } + + /** + * Asserts that the given number of requests has been made on the HttpClient. + * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. + */ + public function assertHttpClientRequestCount(int $count, string $httpClientId = 'http_client'): void + { + $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); + + $this->assertCount($count, $httpClientCollector->getClients()[$httpClientId]['traces']); + } + + /** + * Asserts that the given URL has not been called using GET or the specified method. + * By default, it will check on the HttpClient, but a HttpClient id can be specified. + */ + public function assertNotHttpClientRequest(string $unexpectedUrl, string $expectedMethod = 'GET', string $httpClientId = 'http_client'): void + { + $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); + $unexpectedUrlHasBeenFound = false; + + if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { + $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); + } + + foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { + if (($unexpectedUrl === $trace['info']['url'] || $unexpectedUrl === $trace['url']) + && $expectedMethod === $trace['method'] + ) { + $unexpectedUrlHasBeenFound = true; + break; + } + } + + $this->assertFalse($unexpectedUrlHasBeenFound, sprintf('Unexpected URL called: "%s" - "%s"', $expectedMethod, $unexpectedUrl)); + } + + protected function grabHttpClientCollector(string $function): HttpClientDataCollector + { + return $this->grabCollector('http_client', $function); + } +} diff --git a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php index f8bb9772..df2fd0f1 100644 --- a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php @@ -4,6 +4,8 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\Event\MessageEvents; use Symfony\Component\Mailer\EventListener\MessageLoggerListener; use Symfony\Component\Mailer\Test\Constraint as MailerConstraint; @@ -12,30 +14,47 @@ trait MailerAssertionsTrait { /** - * Checks that no email was sent. - * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: - * If your app performs an HTTP redirect, you need to suppress it using [stopFollowingRedirects()](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) first; otherwise this check will *always* pass. + * Asserts that the expected number of emails was sent. */ - public function dontSeeEmailIsSent(): void + public function assertEmailCount(int $count, ?string $transport = null, string $message = ''): void { - $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount(0)); + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport), $message); } /** - * Checks if the given number of emails was sent (default `$expectedCount`: 1). + * Asserts that the given mailer event is not queued. + * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. + */ + public function assertEmailIsNotQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new LogicalNot(new MailerConstraint\EmailIsQueued()), $message); + } + + /** + * Asserts that the given mailer event is queued. + * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. + */ + public function assertEmailIsQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new MailerConstraint\EmailIsQueued(), $message); + } + + /** + * Asserts that the expected number of emails was queued (e.g. using the Messenger component). + */ + public function assertQueuedEmailCount(int $count, ?string $transport = null, string $message = ''): void + { + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport, true), $message); + } + + /** + * Checks that no email was sent. * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: - * If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) first. - * - * ```php - * <?php - * $I->seeEmailIsSent(2); - * ``` - * - * @param int $expectedCount The expected number of emails sent + * If your app performs an HTTP redirect, you need to suppress it using [stopFollowingRedirects()](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) first; otherwise this check will *always* pass. */ - public function seeEmailIsSent(int $expectedCount = 1): void + public function dontSeeEmailIsSent(): void { - $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($expectedCount)); + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount(0)); } /** @@ -78,6 +97,23 @@ public function grabSentEmails(): array return $this->getMessageMailerEvents()->getMessages(); } + /** + * Checks if the given number of emails was sent (default `$expectedCount`: 1). + * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: + * If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) first. + * + * ```php + * <?php + * $I->seeEmailIsSent(2); + * ``` + * + * @param int $expectedCount The expected number of emails sent + */ + public function seeEmailIsSent(int $expectedCount = 1): void + { + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($expectedCount)); + } + protected function getMessageMailerEvents(): MessageEvents { if ($messageLogger = $this->getService('mailer.message_logger_listener')) { diff --git a/src/Codeception/Module/Symfony/MimeAssertionsTrait.php b/src/Codeception/Module/Symfony/MimeAssertionsTrait.php index 12e73cd8..ba2ee9ac 100644 --- a/src/Codeception/Module/Symfony/MimeAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MimeAssertionsTrait.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Constraint\LogicalNot; use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\RawMessage; use Symfony\Component\Mime\Test\Constraint as MimeConstraint; trait MimeAssertionsTrait @@ -20,7 +21,7 @@ trait MimeAssertionsTrait * $I->assertEmailAddressContains('To', 'jane_doe@example.com'); * ``` */ - public function assertEmailAddressContains(string $headerName, string $expectedValue, Email $email = null): void + public function assertEmailAddressContains(string $headerName, string $expectedValue, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailAddressContains($headerName, $expectedValue)); @@ -35,7 +36,7 @@ public function assertEmailAddressContains(string $headerName, string $expectedV * $I->assertEmailAttachmentCount(1); * ``` */ - public function assertEmailAttachmentCount(int $count, Email $email = null): void + public function assertEmailAttachmentCount(int $count, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailAttachmentCount($count)); @@ -50,7 +51,7 @@ public function assertEmailAttachmentCount(int $count, Email $email = null): voi * $I->assertEmailHasHeader('Bcc'); * ``` */ - public function assertEmailHasHeader(string $headerName, Email $email = null): void + public function assertEmailHasHeader(string $headerName, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailHasHeader($headerName)); @@ -66,7 +67,7 @@ public function assertEmailHasHeader(string $headerName, Email $email = null): v * $I->assertEmailHeaderNotSame('To', 'john_doe@gmail.com'); * ``` */ - public function assertEmailHeaderNotSame(string $headerName, string $expectedValue, Email $email = null): void + public function assertEmailHeaderNotSame(string $headerName, string $expectedValue, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHeaderSame($headerName, $expectedValue))); @@ -82,7 +83,7 @@ public function assertEmailHeaderNotSame(string $headerName, string $expectedVal * $I->assertEmailHeaderSame('To', 'jane_doe@gmail.com'); * ``` */ - public function assertEmailHeaderSame(string $headerName, string $expectedValue, Email $email = null): void + public function assertEmailHeaderSame(string $headerName, string $expectedValue, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailHeaderSame($headerName, $expectedValue)); @@ -97,7 +98,7 @@ public function assertEmailHeaderSame(string $headerName, string $expectedValue, * $I->assertEmailHtmlBodyContains('Successful registration'); * ``` */ - public function assertEmailHtmlBodyContains(string $text, Email $email = null): void + public function assertEmailHtmlBodyContains(string $text, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailHtmlBodyContains($text)); @@ -112,7 +113,7 @@ public function assertEmailHtmlBodyContains(string $text, Email $email = null): * $I->assertEmailHtmlBodyNotContains('userpassword'); * ``` */ - public function assertEmailHtmlBodyNotContains(string $text, Email $email = null): void + public function assertEmailHtmlBodyNotContains(string $text, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHtmlBodyContains($text))); @@ -127,7 +128,7 @@ public function assertEmailHtmlBodyNotContains(string $text, Email $email = null * $I->assertEmailNotHasHeader('Bcc'); * ``` */ - public function assertEmailNotHasHeader(string $headerName, Email $email = null): void + public function assertEmailNotHasHeader(string $headerName, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHasHeader($headerName))); @@ -142,7 +143,7 @@ public function assertEmailNotHasHeader(string $headerName, Email $email = null) * $I->assertEmailTextBodyContains('Example text body'); * ``` */ - public function assertEmailTextBodyContains(string $text, Email $email = null): void + public function assertEmailTextBodyContains(string $text, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new MimeConstraint\EmailTextBodyContains($text)); @@ -157,7 +158,7 @@ public function assertEmailTextBodyContains(string $text, Email $email = null): * $I->assertEmailTextBodyNotContains('My secret text body'); * ``` */ - public function assertEmailTextBodyNotContains(string $text, Email $email = null): void + public function assertEmailTextBodyNotContains(string $text, ?Email $email = null): void { $email = $this->verifyEmailObject($email, __FUNCTION__); $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailTextBodyContains($text))); diff --git a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php index afd160bf..81559730 100644 --- a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php @@ -163,7 +163,7 @@ public function seeUserHasRoles(array $roles): void * * @param UserInterface|null $user */ - public function seeUserPasswordDoesNotNeedRehash(UserInterface $user = null): void + public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): void { if ($user === null) { $security = $this->grabSecurityService(); diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 47e40fc3..aa7ac9e9 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -35,13 +35,13 @@ trait SessionAssertionsTrait * $I->amLoggedInAs($user); * ``` */ - public function amLoggedInAs(UserInterface $user, string $firewallName = 'main', string $firewallContext = null): void + public function amLoggedInAs(UserInterface $user, string $firewallName = 'main', ?string $firewallContext = null): void { $token = $this->createAuthenticationToken($user, $firewallName); $this->loginWithToken($token, $firewallName, $firewallContext); } - public function amLoggedInWithToken(TokenInterface $token, string $firewallName = 'main', string $firewallContext = null): void + public function amLoggedInWithToken(TokenInterface $token, string $firewallName = 'main', ?string $firewallContext = null): void { $this->loginWithToken($token, $firewallName, $firewallContext); }