diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b409e42..22d0d825 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,20 +108,30 @@ Calling an instance method through `self::` or `static::` is not in this list - ## Layers -The package ships 2 layers, and the dependency only runs one way. +The package ships 3 layers, and the dependency only runs one way: `Steps` on `Behat` on `Driver`. - **`src/Driver`** is the part that talks to Drupal: it bootstraps a site in-process or shells out to Drush, creates entities, and expands field values into their storage shape. It knows nothing about Behat or Mink, which is what keeps it usable outside a Behat run. +- **`src/Behat`** is the integration: `ServiceContainer/BehatStepsExtension` reads the `behat_steps` configuration and builds the container, `Manager/` holds the driver, authentication, user and mail managers, `Context/RawContext` is the base context a consuming `FeatureContext` extends, and `Hook/`, `Listener/`, `Selector/` and `Generator/` carry the entity-creation hooks, the per-scenario driver selection, the `region` Mink selector and the starter-class generator. `RawContext` registers no step definitions - it owns the scenario lifecycle only. - **`src/Steps`** is the step vocabulary - traits a consuming `FeatureContext` mixes in. `Generic/` holds the framework-agnostic ones, `Drupal/` the ones that need a Drupal site, and the directory a trait sits in is the context [STEPS.md](STEPS.md) groups it under. A trait names the context class it needs with `@phpstan-require-extends`, and never composes another step trait: shared logic goes in the step-free `HelperTrait` of its context. -[scripts/lint-layers.php](scripts/lint-layers.php) holds that boundary. It reads every file under `src/Driver` and fails on any code reference into the `Behat` or `Mink` namespaces: imports, type declarations, and class names reached through a string. A prose mention in a comment is fine - it's the code references that matter. `ahoy lint` runs it. +[scripts/lint-layers.php](scripts/lint-layers.php) holds the lower boundary. It reads every file under `src/Driver` and fails on any code reference into the `Behat` or `Mink` namespaces: imports, type declarations, and class names reached through a string. A prose mention in a comment is fine - it's the code references that matter. `ahoy lint` runs it. + +## Behat 4 readiness + +`src/Behat` plugs into 4 Behat extension points, and each one is written to satisfy Behat 3.32 and Behat 4 at the same time. Keep it that way when touching them. + +- **Signatures are typed for Behat 4, widened for Behat 3.** Behat 4 types its interfaces where 3.32 leaves them untyped, so implementations declare the Behat 4 return type (`ClassGenerator::supportsSuiteAndClass(): bool`, `HookScope::getName(): string`, `FilterableHook::filterMatches(): bool`, `Extension::getConfigKey(): string`) and keep the parameter untyped or `mixed` so the 3.32 interface is not narrowed. +- **`DriverListener` reads the event, not the removed interface.** Behat 4 drops `ScenarioLikeTested`. Both `ScenarioTested::BEFORE` and `ExampleTested::BEFORE` carry a `BeforeScenarioTested`, which declares `getFeature()` and `getScenario()` itself in both versions, so the listener type-hints that class. +- **`HookAttributeReader` builds its callable through Behat's factory when there is one.** Behat 4 types the callee constructor as `callable`, and `[class-string, method]` is not callable for an instance method. `ContextMethodCallableFactory` wraps such methods on Behat 4 and is absent on Behat 3, so `makeCallable()` uses it only when the class exists. +- **The `context.class_generator.simple` override survives by service id.** Behat collects generators by tag before an activated extension's `process()` runs and injects them as references, so replacing the definition behind that id swaps the class in both versions. ## Dependency policy Keep the `require` section of `composer.json` minimal - it should contain only what **every** consumer needs regardless of which traits they use. -- **`require`**: the framework and browser abstraction that virtually all steps build on - `php`, `behat/behat`, `behat/mink` - plus what the driver layer needs at runtime. The driver ships in `src/`, so every consumer loads it: `drupal/core-utility`, `symfony/dependency-injection`, `symfony/process`. +- **`require`**: the framework and browser abstraction that virtually all steps build on - `php`, `behat/behat`, `behat/mink` - plus what the driver and Behat layers need at runtime. Both ship in `src/`, so every consumer loads them: `drupal/core-utility`, `symfony/process` for the driver, and `friends-of-behat/mink-extension`, `symfony/config`, `symfony/dependency-injection`, `symfony/event-dispatcher` for the extension, its config schema and `RawContext`'s Mink ancestor. - **`require-dev` + `suggest`**: any package used by only a subset of traits. List it in `require-dev` so this library's own test suite still exercises it, **and** in `suggest` with a message naming the exact trait(s) or step(s) that need it (as `justinrainbow/json-schema` does for `JsonTrait`). When a new trait needs a package, decide up front: trait-specific packages go in `require-dev` + `suggest`, never in `require`. Demoting a package from `require` to `suggest` later is a breaking change for consumers relying on transitive installation, so batch such demotions into the next major release and document them in [MIGRATION.md](MIGRATION.md). diff --git a/composer.json b/composer.json index 7a54f628..c430a2a6 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,12 @@ "behat/behat": "^3.32.0", "behat/mink": ">=1.13.0", "drupal/core-utility": "^11", + "friends-of-behat/mink-extension": "^2.7.5", + "symfony/config": "^6.4.3 || ^7", "symfony/dependency-injection": "^6.4 || ^7", - "symfony/process": "^6.4 || ^7" + "symfony/event-dispatcher": "^6.4 || ^7", + "symfony/process": "^6.4 || ^7", + "symfony/yaml": "^6.4 || ^7" }, "require-dev": { "alexskrypnyk/phpunit-helpers": "^1.1.0", diff --git a/src/Behat/Context/Attribute/HookAttributeReader.php b/src/Behat/Context/Attribute/HookAttributeReader.php new file mode 100644 index 00000000..4ffe4883 --- /dev/null +++ b/src/Behat/Context/Attribute/HookAttributeReader.php @@ -0,0 +1,107 @@ +> + */ + protected const ATTRIBUTE_MAP = [ + AfterEntityCreateAttribute::class => AfterEntityCreate::class, + AfterNodeCreateAttribute::class => AfterNodeCreate::class, + AfterTermCreateAttribute::class => AfterTermCreate::class, + AfterUserCreateAttribute::class => AfterUserCreate::class, + BeforeEntityCreateAttribute::class => BeforeEntityCreate::class, + BeforeNodeCreateAttribute::class => BeforeNodeCreate::class, + BeforeTermCreateAttribute::class => BeforeTermCreate::class, + BeforeUserCreateAttribute::class => BeforeUserCreate::class, + ]; + + /** + * {@inheritdoc} + * + * @param class-string<\Behat\Behat\Context\Context> $contextClass + * The context class name. + * @param \ReflectionMethod $method + * The reflected method. + */ + public function readCallees(string $contextClass, \ReflectionMethod $method): array { + $attributes = $method->getAttributes(DrupalHookInterface::class, \ReflectionAttribute::IS_INSTANCEOF); + + $callees = []; + foreach ($attributes as $attribute) { + $hook_call_class = self::ATTRIBUTE_MAP[$attribute->getName()] ?? NULL; + if ($hook_call_class === NULL) { + continue; + } + + $hook = $attribute->newInstance(); + $callees[] = new $hook_call_class($hook->getFilterString(), $this->makeCallable($contextClass, $method)); + } + + return $callees; + } + + /** + * Builds the callable a hook call is constructed with. + * + * @param class-string<\Behat\Behat\Context\Context> $context_class + * The context class declaring the method. + * @param \ReflectionMethod $method + * The reflected method carrying the attribute. + * + * @return array{class-string<\Behat\Behat\Context\Context>, string}|callable + * The pair Behat 3 accepts, or the wrapper Behat 4 requires. + */ + protected function makeCallable(string $context_class, \ReflectionMethod $method): array|callable { + if ($method->isStatic() || !class_exists(self::CALLABLE_FACTORY)) { + return [$context_class, $method->getName()]; + } + + // @codeCoverageIgnoreStart + /** @var callable $callable */ + // @phpstan-ignore argument.type + $callable = call_user_func([self::CALLABLE_FACTORY, 'makeCallable'], $context_class, $method); + + return $callable; + // @codeCoverageIgnoreEnd + } + +} diff --git a/src/Behat/Context/DriverAwareInterface.php b/src/Behat/Context/DriverAwareInterface.php new file mode 100644 index 00000000..52273c66 --- /dev/null +++ b/src/Behat/Context/DriverAwareInterface.php @@ -0,0 +1,59 @@ + $parameters + * Configuration parameters. + * @param \Behat\Testwork\Hook\HookDispatcher $hookDispatcher + * The hook dispatcher. + * @param \DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface $authenticationManager + * The authentication manager. + * @param \DrevOps\BehatSteps\Behat\Manager\UserManagerInterface $userManager + * The user manager. + */ + public function __construct( + protected readonly DriverManagerInterface $driverManager, + protected readonly array $parameters, + protected readonly HookDispatcher $hookDispatcher, + protected readonly AuthenticationManagerInterface $authenticationManager, + protected readonly UserManagerInterface $userManager, + ) { + } + + /** + * {@inheritdoc} + */ + public function initializeContext(Context $context): void { + // 'ParametersAwareInterface' is a strict subset of 'DriverAwareInterface' + // (the latter extends the former). Pass parameters to any context that + // asks for them, then layer the heavier driver wiring on top for full + // driver-aware contexts only. + if ($context instanceof ParametersAwareInterface) { + $context->setParameters($this->parameters); + } + + if (!$context instanceof DriverAwareInterface) { + return; + } + + $context->setDriverManager($this->driverManager); + $context->setDispatcher($this->hookDispatcher); + $context->setAuthenticationManager($this->authenticationManager); + $context->setUserManager($this->userManager); + } + +} diff --git a/src/Behat/Context/RawContext.php b/src/Behat/Context/RawContext.php new file mode 100644 index 00000000..b88fa489 --- /dev/null +++ b/src/Behat/Context/RawContext.php @@ -0,0 +1,670 @@ + + */ + protected array $createdStubs = []; + + /** + * Roles created during a scenario, so they can be removed after it. + * + * @var array + */ + protected array $roles = []; + + /** + * Converts textual node timestamps into the numeric form storage expects. + * + * @throws \RuntimeException + * When a timestamp value cannot be read as a date. + */ + #[BeforeNodeCreate] + public static function alterNodeParameters(BeforeNodeCreateScope $scope): void { + $stub = $scope->getStub(); + + // Blackbox and Drush drivers route around this entity pipeline entirely, + // so converting string dates on timestamp fields only means anything for + // the in-process driver. + $context = $scope->getContext(); + + if (!$context instanceof DriverAwareInterface) { + return; + } + + if (!$context->getDriverManager()->getDriver() instanceof DrupalDriver) { + return; + } + + foreach (['changed', 'created', 'revision_timestamp'] as $field) { + $value = $stub->getValue($field); + + if ($value === NULL || $value === '' || is_numeric($value)) { + continue; + } + + $timestamp = strtotime((string) $value); + + if ($timestamp === FALSE) { + throw new \RuntimeException(sprintf('Unable to read the "%s" value "%s" as a date.', $field, (string) $value)); + } + + $stub->setValue($field, $timestamp); + } + } + + /** + * Removes every entity created during the scenario. + * + * Walks 'createdStubs' in reverse order so dependent entities (a node + * referencing a term, say) come down before the entities they reference. + */ + #[AfterScenario] + public function cleanEntities(): void { + if (!$this->shouldCleanup()) { + return; + } + + if ($this->createdStubs === []) { + return; + } + + $driver = $this->getDriver(); + + foreach (array_reverse($this->createdStubs) as $stub) { + $this->deleteStub($stub, $driver); + } + + $this->createdStubs = []; + } + + /** + * Removes any created users. + * + * The early-return guard also skips the logout below, because + * 'BEHAT_STEPS_DISABLE_CLEANUP' is there to leave the failing scenario's + * state intact, session included. + * + * Later scenarios in the same run inherit that login. + */ + #[AfterScenario] + public function cleanUsers(): void { + if (!$this->shouldCleanup()) { + return; + } + + $driver = $this->getDriver(); + $user_manager = $this->getUserManager(); + + if ($user_manager->hasUsers() && $driver instanceof UserCapabilityInterface) { + foreach ($user_manager->getUsers() as $user) { + $driver->userDelete($user); + } + + if ($driver instanceof BatchCapabilityInterface) { + $driver->processBatch(); + } + + $user_manager->clearUsers(); + } + + // Reset auth state even when the scenario created no users: a scenario + // may log in as a pre-existing user without calling userCreate(), leaving + // stale session state for the next scenario. + if ($this->getAuthenticationManager() instanceof FastLogoutInterface) { + $this->logout(TRUE); + } + elseif (!$user_manager->currentUserIsAnonymous()) { + $this->logout(); + } + } + + /** + * Removes any created roles. + */ + #[AfterScenario] + public function cleanRoles(): void { + if (!$this->shouldCleanup()) { + return; + } + + if ($this->roles === []) { + return; + } + + $driver = $this->getDriver(); + + if (!$driver instanceof RoleCapabilityInterface) { + return; + } + + foreach ($this->roles as $role) { + $driver->roleDelete($role); + } + + $this->roles = []; + } + + /** + * Clears static caches. + */ + #[AfterScenario('@api')] + public function clearStaticCaches(): void { + $driver = $this->getDriver(); + + if ($driver instanceof CacheCapabilityInterface) { + $driver->cacheClearStatic(); + } + } + + /** + * {@inheritdoc} + */ + public function setDriverManager(DriverManagerInterface $driverManager): void { + $this->driverManager = $driverManager; + } + + /** + * {@inheritdoc} + */ + public function getDriverManager(): DriverManagerInterface { + if (!$this->driverManager instanceof DriverManagerInterface) { + throw new \RuntimeException('The driver manager is available only after Behat has initialized the context.'); + } + + return $this->driverManager; + } + + /** + * {@inheritdoc} + */ + public function setDispatcher(HookDispatcher $dispatcher): void { + $this->dispatcher = $dispatcher; + } + + /** + * {@inheritdoc} + */ + public function setUserManager(UserManagerInterface $userManager): void { + $this->userManager = $userManager; + } + + /** + * {@inheritdoc} + */ + public function getUserManager(): UserManagerInterface { + if (!$this->userManager instanceof UserManagerInterface) { + throw new \RuntimeException('The user manager is available only after Behat has initialized the context.'); + } + + return $this->userManager; + } + + /** + * {@inheritdoc} + */ + public function setAuthenticationManager(AuthenticationManagerInterface $authenticationManager): void { + $this->authenticationManager = $authenticationManager; + } + + /** + * {@inheritdoc} + */ + public function getAuthenticationManager(): AuthenticationManagerInterface { + if (!$this->authenticationManager instanceof AuthenticationManagerInterface) { + throw new \RuntimeException('The authentication manager is available only after Behat has initialized the context.'); + } + + return $this->authenticationManager; + } + + /** + * Returns the active driver. + * + * @param string|null $name + * The driver name, or NULL for the scenario's default driver. + */ + public function getDriver(?string $name = NULL): DriverInterface { + return $this->getDriverManager()->getDriver($name); + } + + /** + * Returns the driver's random generator. + */ + public function getRandom(): Random { + return $this->getDriver()->getRandom(); + } + + /** + * Creates a node. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The node stub. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface + * The same stub, now flagged as saved. + */ + public function nodeCreate(EntityStubInterface $stub): EntityStubInterface { + $this->dispatchHooks(BeforeNodeCreateScope::class, $stub); + $this->dispatchHooks(BeforeEntityCreateScope::class, $stub); + + $driver = $this->getContentDriver(); + + $scalars = $this->captureScalarBaseFields($stub); + $driver->nodeCreate($stub); + $this->restoreScalarBaseFields($stub, $scalars); + + // Register before the post-create hooks run: a hook that throws still + // leaves the entity behind, and cleanup can only remove what it knows. + $this->createdStubs[] = $stub; + + $this->dispatchHooks(AfterNodeCreateScope::class, $stub); + $this->dispatchHooks(AfterEntityCreateScope::class, $stub); + + return $stub; + } + + /** + * Creates a user. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The user stub. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface + * The same stub, now flagged as saved. + * + * @throws \RuntimeException + * When the active driver cannot create users. + */ + public function userCreate(EntityStubInterface $stub): EntityStubInterface { + $this->dispatchHooks(BeforeUserCreateScope::class, $stub); + $this->dispatchHooks(BeforeEntityCreateScope::class, $stub); + + $driver = $this->getDriver(); + + if (!$driver instanceof UserCapabilityInterface) { + throw new \RuntimeException(sprintf('The active Drupal driver "%s" does not support user creation.', $driver::class)); + } + + $scalars = $this->captureScalarBaseFields($stub); + $driver->userCreate($stub); + $this->restoreScalarBaseFields($stub, $scalars); + + // Register before the post-create hooks run: a hook that throws still + // leaves the user behind, and cleanup can only remove what it knows. + $this->getUserManager()->addUser($stub); + + $this->dispatchHooks(AfterUserCreateScope::class, $stub); + $this->dispatchHooks(AfterEntityCreateScope::class, $stub); + + return $stub; + } + + /** + * Creates a taxonomy term. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The term stub. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface + * The same stub, now flagged as saved. + */ + public function termCreate(EntityStubInterface $stub): EntityStubInterface { + // The driver loads vocabularies by machine name only, so resolve a human + // label to one first. The resolution is best-effort - the driver reports + // a clearer failure than this could. + $vocabulary = $stub->getValue('vocabulary_machine_name'); + + if (!empty($vocabulary)) { + $stub->setValue('vocabulary_machine_name', $this->resolveVocabularyMachineName((string) $vocabulary)); + } + + // The driver resolves 'parent' as a term name in the same vocabulary, so + // pass it through unchanged. An empty value is removed, because the field + // pipeline would try to expand the empty string as an entity reference. + if ($stub->hasValue('parent') && empty($stub->getValue('parent'))) { + $stub->removeValue('parent'); + } + + $this->dispatchHooks(BeforeTermCreateScope::class, $stub); + $this->dispatchHooks(BeforeEntityCreateScope::class, $stub); + + $driver = $this->getContentDriver(); + + $scalars = $this->captureScalarBaseFields($stub); + $driver->termCreate($stub); + $this->restoreScalarBaseFields($stub, $scalars); + + // Register before the post-create hooks run: a hook that throws still + // leaves the term behind, and cleanup can only remove what it knows. + $this->createdStubs[] = $stub; + + $this->dispatchHooks(AfterTermCreateScope::class, $stub); + $this->dispatchHooks(AfterEntityCreateScope::class, $stub); + + return $stub; + } + + /** + * Creates an entity of a type that has no dedicated method. + * + * The stub joins 'createdStubs', so 'cleanEntities()' removes it after the + * scenario through the driver's 'entityDelete()' fallback. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The entity stub. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface + * The same stub, now flagged as saved. + */ + public function entityCreate(EntityStubInterface $stub): EntityStubInterface { + $this->dispatchHooks(BeforeEntityCreateScope::class, $stub); + + $driver = $this->getContentDriver(); + + $scalars = $this->captureScalarBaseFields($stub); + $driver->entityCreate($stub); + $this->restoreScalarBaseFields($stub, $scalars); + + // Register before the post-create hook runs: a hook that throws still + // leaves the entity behind, and cleanup can only remove what it knows. + $this->createdStubs[] = $stub; + + $this->dispatchHooks(AfterEntityCreateScope::class, $stub); + + return $stub; + } + + /** + * Creates a language. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * Language stub. Must carry a 'langcode' value. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface|false + * The created language stub, or FALSE if the language already existed. + * + * @throws \RuntimeException + * When the active driver cannot manage languages. + */ + public function languageCreate(EntityStubInterface $stub): EntityStubInterface|false { + $this->dispatchHooks(BeforeLanguageCreateScope::class, $stub); + + $driver = $this->getDriver(); + + if (!$driver instanceof LanguageCapabilityInterface) { + throw new \RuntimeException(sprintf('The active Drupal driver "%s" does not support language management.', $driver::class)); + } + + $result = $driver->languageCreate($stub); + + if ($result === FALSE) { + return FALSE; + } + + // Register before the post-create hook runs: a hook that throws still + // leaves the language behind, and cleanup can only remove what it knows. + $this->createdStubs[] = $result; + + $this->dispatchHooks(AfterLanguageCreateScope::class, $result); + + return $result; + } + + /** + * Logs the given user in. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $user + * The user stub to log in. + */ + public function login(EntityStubInterface $user): void { + $this->getAuthenticationManager()->logIn($user); + } + + /** + * Logs the current user out. + * + * @param bool $fast + * Reset the session directly where the manager supports it. + */ + public function logout(bool $fast = FALSE): void { + $authentication_manager = $this->getAuthenticationManager(); + + if ($fast && $authentication_manager instanceof FastLogoutInterface) { + $authentication_manager->fastLogout(); + } + else { + $authentication_manager->logOut(); + } + } + + /** + * Determines whether a user is logged in for this session. + */ + public function loggedIn(): bool { + return $this->getAuthenticationManager()->loggedIn(); + } + + /** + * Routes a stub to the right per-type driver delete method. + */ + protected function deleteStub(EntityStubInterface $stub, DriverInterface $driver): void { + $type = $stub->getEntityType(); + + if (in_array($type, ['language', 'configurable_language'], TRUE)) { + if ($driver instanceof LanguageCapabilityInterface) { + $driver->languageDelete($stub); + } + + return; + } + + if (!$driver instanceof ContentCapabilityInterface) { + return; + } + + match ($type) { + 'node' => $driver->nodeDelete($stub), + 'taxonomy_term' => $driver->termDelete($stub), + default => $driver->entityDelete($stub), + }; + } + + /** + * Determines whether scenario cleanup should run. + * + * Set 'BEHAT_STEPS_DISABLE_CLEANUP' to '1', 'true', 'yes', or 'on' + * (case-insensitive) to skip the AfterScenario teardown of entities, users + * and roles. Useful for inspecting state left behind by a failing scenario; + * not intended for CI runs. + */ + protected function shouldCleanup(): bool { + $env = getenv('BEHAT_STEPS_DISABLE_CLEANUP'); + + if ($env === FALSE || $env === '') { + return TRUE; + } + + return !in_array(strtolower(trim($env)), ['1', 'true', 'yes', 'on'], TRUE); + } + + /** + * Dispatches the hooks registered for a scope. + * + * @param class-string<\DrevOps\BehatSteps\Behat\Hook\Scope\BaseEntityScope> $scopeClass + * The fully-qualified scope class name. + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The entity stub flowing through the create pipeline. + * + * @throws \RuntimeException + * When the context has not been initialized by Behat. + */ + protected function dispatchHooks(string $scopeClass, EntityStubInterface $stub): void { + if (!$this->dispatcher instanceof HookDispatcher) { + throw new \RuntimeException('The hook dispatcher is available only after Behat has initialized the context.'); + } + + $environment = $this->getDriverManager()->getEnvironment(); + + if (!$environment instanceof Environment) { + throw new \RuntimeException('Hooks can be dispatched only once a scenario has started.'); + } + + $scope = new $scopeClass($environment, $this, $stub); + $call_results = $this->dispatcher->dispatchScopeHooks($scope); + + // The dispatcher collects exceptions rather than raising them, so surface + // the first one here. + foreach ($call_results as $call_result) { + $exception = $call_result->getException(); + + if ($exception instanceof \Throwable) { + throw $exception; + } + } + } + + /** + * Resolves a vocabulary identifier to its machine name. + * + * Accepts either the machine name (returned as-is) or the human label + * (looked up via the vocabulary storage). Falls back to the original value + * when no label matches, leaving the driver to surface a not-found error. + */ + protected function resolveVocabularyMachineName(string $identifier): string { + if (!class_exists(Vocabulary::class) || Vocabulary::load($identifier) instanceof Vocabulary) { + return $identifier; + } + + foreach (Vocabulary::loadMultiple() as $vocabulary) { + if ($vocabulary->label() === $identifier) { + return (string) $vocabulary->id(); + } + } + + return $identifier; + } + + /** + * Captures the scalar values on an entity stub. + * + * The driver runs base fields through the field-handler pipeline during + * create, which casts scalar values such as 'title', 'name', 'mail' or + * 'pass' to single-element arrays. Downstream code (user manager indexing, + * login flow, stub matching) expects scalars, so callers snapshot them + * before the driver call and restore them after. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The entity stub to inspect. + * + * @return array + * The scalar values keyed by name. + */ + protected function captureScalarBaseFields(EntityStubInterface $stub): array { + return array_filter($stub->getValues(), is_scalar(...)); + } + + /** + * Restores scalar values previously captured. + * + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface $stub + * The entity stub to mutate. + * @param array $scalars + * Map of value name to original scalar value. + */ + protected function restoreScalarBaseFields(EntityStubInterface $stub, array $scalars): void { + foreach ($scalars as $field => $value) { + $stub->setValue($field, $value); + } + } + + /** + * Resolves the active driver as a content-capable instance. + * + * @throws \RuntimeException + * When the active driver does not implement 'ContentCapabilityInterface'. + */ + protected function getContentDriver(): ContentCapabilityInterface { + $driver = $this->getDriver(); + + if (!$driver instanceof ContentCapabilityInterface) { + throw new \RuntimeException(sprintf('The active Drupal driver "%s" does not support content creation.', $driver::class)); + } + + return $driver; + } + +} diff --git a/src/Behat/Generator/ClassGenerator.php b/src/Behat/Generator/ClassGenerator.php new file mode 100644 index 00000000..31b3eaf4 --- /dev/null +++ b/src/Behat/Generator/ClassGenerator.php @@ -0,0 +1,78 @@ + $namespace, + '{className}' => $contextClass, + ]); + } + +} diff --git a/src/Behat/Hook/Attribute/AfterEntityCreate.php b/src/Behat/Hook/Attribute/AfterEntityCreate.php new file mode 100644 index 00000000..1f312fad --- /dev/null +++ b/src/Behat/Hook/Attribute/AfterEntityCreate.php @@ -0,0 +1,15 @@ +filterString; + } + +} diff --git a/src/Behat/Hook/Call/AfterEntityCreate.php b/src/Behat/Hook/Call/AfterEntityCreate.php new file mode 100644 index 00000000..06e262e7 --- /dev/null +++ b/src/Behat/Hook/Call/AfterEntityCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(EntityScopeInterface::AFTER, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'AfterEntityCreate'; + } + +} diff --git a/src/Behat/Hook/Call/AfterNodeCreate.php b/src/Behat/Hook/Call/AfterNodeCreate.php new file mode 100644 index 00000000..b6f288f9 --- /dev/null +++ b/src/Behat/Hook/Call/AfterNodeCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(NodeScope::AFTER, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'AfterNodeCreate'; + } + +} diff --git a/src/Behat/Hook/Call/AfterTermCreate.php b/src/Behat/Hook/Call/AfterTermCreate.php new file mode 100644 index 00000000..7229fafe --- /dev/null +++ b/src/Behat/Hook/Call/AfterTermCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(TermScope::AFTER, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'AfterTermCreate'; + } + +} diff --git a/src/Behat/Hook/Call/AfterUserCreate.php b/src/Behat/Hook/Call/AfterUserCreate.php new file mode 100644 index 00000000..cb747108 --- /dev/null +++ b/src/Behat/Hook/Call/AfterUserCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(UserScope::AFTER, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'AfterUserCreate'; + } + +} diff --git a/src/Behat/Hook/Call/BeforeEntityCreate.php b/src/Behat/Hook/Call/BeforeEntityCreate.php new file mode 100644 index 00000000..33311a72 --- /dev/null +++ b/src/Behat/Hook/Call/BeforeEntityCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(EntityScopeInterface::BEFORE, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'BeforeEntityCreate'; + } + +} diff --git a/src/Behat/Hook/Call/BeforeNodeCreate.php b/src/Behat/Hook/Call/BeforeNodeCreate.php new file mode 100644 index 00000000..14bb26e1 --- /dev/null +++ b/src/Behat/Hook/Call/BeforeNodeCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(NodeScope::BEFORE, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'BeforeNodeCreate'; + } + +} diff --git a/src/Behat/Hook/Call/BeforeTermCreate.php b/src/Behat/Hook/Call/BeforeTermCreate.php new file mode 100644 index 00000000..f4f24bb0 --- /dev/null +++ b/src/Behat/Hook/Call/BeforeTermCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(TermScope::BEFORE, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'BeforeTermCreate'; + } + +} diff --git a/src/Behat/Hook/Call/BeforeUserCreate.php b/src/Behat/Hook/Call/BeforeUserCreate.php new file mode 100644 index 00000000..ea2bfbcb --- /dev/null +++ b/src/Behat/Hook/Call/BeforeUserCreate.php @@ -0,0 +1,35 @@ +, string}|callable $callable + * The context method to call. + * @param string|null $description + * A human readable description of the hook. + */ + public function __construct(?string $filterString, array|callable $callable, ?string $description = NULL) { + parent::__construct(UserScope::BEFORE, $filterString, $callable, $description); + } + + /** + * {@inheritdoc} + */ + public function getName(): string { + return 'BeforeUserCreate'; + } + +} diff --git a/src/Behat/Hook/Call/EntityHook.php b/src/Behat/Hook/Call/EntityHook.php new file mode 100644 index 00000000..49d23497 --- /dev/null +++ b/src/Behat/Hook/Call/EntityHook.php @@ -0,0 +1,25 @@ +getFilterString() === NULL; + } + +} diff --git a/src/Behat/Hook/Scope/AfterEntityCreateScope.php b/src/Behat/Hook/Scope/AfterEntityCreateScope.php new file mode 100644 index 00000000..a905aeea --- /dev/null +++ b/src/Behat/Hook/Scope/AfterEntityCreateScope.php @@ -0,0 +1,19 @@ +context; + } + + /** + * {@inheritdoc} + */ + public function getStub(): EntityStubInterface { + return $this->entityStub; + } + + /** + * {@inheritdoc} + */ + public function getEnvironment(): Environment { + return $this->environment; + } + + /** + * {@inheritdoc} + */ + public function getSuite(): Suite { + return $this->environment->getSuite(); + } + +} diff --git a/src/Behat/Hook/Scope/BeforeEntityCreateScope.php b/src/Behat/Hook/Scope/BeforeEntityCreateScope.php new file mode 100644 index 00000000..144919cb --- /dev/null +++ b/src/Behat/Hook/Scope/BeforeEntityCreateScope.php @@ -0,0 +1,19 @@ + $parameters + * Test parameters. + */ + public function __construct( + protected readonly DriverManagerInterface $driverManager, + protected array $parameters, + ) { + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + ScenarioTested::BEFORE => ['prepareDefaultDriver', 11], + ExampleTested::BEFORE => ['prepareDefaultDriver', 11], + ]; + } + + /** + * Sets the default driver for the scenario or example about to run. + * + * A tag named '' selects the driver configured as '_driver', so + * an '@api' scenario runs against 'api_driver'. Scenarios carrying no such + * tag run against 'default_driver'. + * + * Both subscribed events carry a 'BeforeScenarioTested', an example's + * scenario being the outline row itself. + * + * @throws \RuntimeException + * When neither a tag nor 'default_driver' names a driver. + */ + public function prepareDefaultDriver(BeforeScenarioTested $event): void { + $driver = $this->parameters['default_driver'] ?? NULL; + + $tags = $event->getFeature()->getTags(); + $scenario = $event->getScenario(); + + if ($scenario instanceof TaggedNodeInterface) { + $tags = array_merge($tags, $scenario->getTags()); + } + + foreach ($tags as $tag) { + if (!empty($this->parameters[$tag . '_driver'])) { + $driver = $this->parameters[$tag . '_driver']; + } + } + + if (!is_string($driver) || $driver === '') { + throw new \RuntimeException('No driver is configured for this scenario: set "default_driver" in the extension configuration.'); + } + + $this->driverManager->setDefaultDriverName($driver); + $this->driverManager->setEnvironment($event->getEnvironment()); + } + +} diff --git a/src/Behat/Manager/AuthenticationManager.php b/src/Behat/Manager/AuthenticationManager.php new file mode 100644 index 00000000..29c8b310 --- /dev/null +++ b/src/Behat/Manager/AuthenticationManager.php @@ -0,0 +1,309 @@ + $minkParameters + * Mink configuration parameters. + * @param array $parameters + * Extension parameters. + */ + public function __construct( + Mink $mink, + protected UserManagerInterface $userManager, + protected DriverManagerInterface $driverManager, + array $minkParameters, + array $parameters, + ) { + $this->setMink($mink); + $this->setMinkParameters($minkParameters); + $this->setParameters($parameters); + } + + /** + * {@inheritdoc} + */ + public function logIn(EntityStubInterface $user): void { + // Log out any existing user before logging in a new user. + $this->fastLogout(); + + $session = $this->getSession(); + + $login_url = $this->locatePath($this->getDrupalText('login_url')); + $session->visit($login_url); + + $name = (string) $user->getValue('name'); + $pass = (string) $user->getValue('pass'); + + // Which user property is submitted as the login value. Defaults to 'name' + // but may be set to 'mail' for sites that authenticate by email or to any + // other user entity property. + $login_field = (string) ($this->getParameter('login_field') ?: 'name'); + $login_value = (string) $user->getValue($login_field); + + $page = $session->getPage(); + $page->fillField($this->getDrupalText('username_field'), $login_value); + $page->fillField($this->getDrupalText('password_field'), $pass); + + $login_element = $this->getLoginElement($page); + if (!$login_element instanceof NodeElement) { + throw new ElementNotFoundException($session->getDriver(), 'submit button', 'css', 'login form'); + } + $login_element->click(); + + $login_wait = (int) $this->getParameter('login_wait'); + if ($login_wait > 0) { + // Wait for the redirect away from the login form. + $timeout = microtime(TRUE) + $login_wait; + while (microtime(TRUE) < $timeout && $session->getCurrentUrl() === $login_url) { + usleep(100000); + } + + // Wait for the page body to render. + $timeout = microtime(TRUE) + $login_wait; + while (microtime(TRUE) < $timeout && !$session->getPage()->find('css', 'body')) { + usleep(100000); + } + + // The logged-in selector may be added by JS or AJAX after the render. + $timeout = microtime(TRUE) + $login_wait; + while (microtime(TRUE) < $timeout && !$session->getPage()->has('css', $this->getDrupalSelector('logged_in_selector'))) { + usleep(100000); + } + } + + if (!$this->loggedIn()) { + $role = $user->getValue('role'); + $message = $role !== NULL ? sprintf("Unable to determine if logged in because '%s' ('log_out') link cannot be found for user '%s' with role '%s'", $this->getDrupalText('log_out'), $name, $role) : sprintf("Unable to determine if logged in because '%s' ('log_out') link cannot be found for user '%s'", $this->getDrupalText('log_out'), $name); + throw new ExpectationException($message, $session->getDriver()); + } + + $this->userManager->setCurrentUser($user); + + $this->backendLogin($user); + } + + /** + * {@inheritdoc} + */ + public function logOut(): void { + $session = $this->getSession(); + + $logout_url = $this->locatePath($this->getDrupalText('logout_url')); + $logout_confirm_url = $this->locatePath($this->getDrupalText('logout_confirm_url')); + + $session->visit($logout_url); + + if ($session->getCurrentUrl() === $logout_confirm_url) { + $logout_element = $this->getLogoutConfirmElement($session->getPage()); + + if (!$logout_element instanceof NodeElement) { + throw new ElementNotFoundException($session->getDriver(), 'logout button', 'css', 'logout confirmation page'); + } + + $logout_element->click(); + } + + $this->userManager->setCurrentUser(FALSE); + + $this->backendLogout(); + } + + /** + * {@inheritdoc} + */ + public function loggedIn(): bool { + $session = $this->getSession(); + + // A session that has not started has no user logged in. + if (!$session->isStarted()) { + return FALSE; + } + + // A nullsafe check here keeps PHPStan from flagging the non-nullable + // 'getPage()' return type while still letting test doubles that return + // 'NULL' short-circuit safely. + $page = $session->getPage(); + if ($page === NULL) { + return FALSE; + } + + // The logged-in class on the body tag works with almost any theme. + try { + if ($page->has('css', $this->getDrupalSelector('logged_in_selector'))) { + return TRUE; + } + } + catch (DriverException) { + // The driver has not loaded a page yet. + } + + // Some themes do not add that class to the body, so fall back to the + // presence of the login form. + $login_url = $this->locatePath($this->getDrupalText('login_url')); + $session->visit($login_url); + if ($page->has('css', $this->getDrupalSelector('login_form_selector'))) { + $this->fastLogout(); + + return FALSE; + } + + // As a last resort, a logout link means a user is logged in. On themes + // that defer header navigation (through Critical CSS or a late JS render) + // the link may be absent at the moment of this lookup, so poll for it + // within the same window 'login_wait' configures for the post-submit + // waits in 'logIn()'. + $session->visit($this->locatePath('/')); + $login_wait = (int) $this->getParameter('login_wait'); + if ($login_wait > 0) { + $timeout = microtime(TRUE) + $login_wait; + while (microtime(TRUE) < $timeout && !$this->getLogoutElement() instanceof NodeElement) { + usleep(100000); + } + } + if ($this->getLogoutElement() instanceof NodeElement) { + return TRUE; + } + + // The user appears to be anonymous, so reset the session fully rather + // than leave a partially logged-in state behind. + $this->fastLogout(); + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function fastLogout(): void { + $session = $this->getSession(); + if ($session->isStarted()) { + $session->reset(); + // Resetting clears request headers, including basic auth, so requests + // after the reset would 401 on sites behind webserver-level basic auth. + $this->applyBasicAuth(); + } + + $this->userManager->setCurrentUser(FALSE); + + $this->backendLogout(); + } + + /** + * {@inheritdoc} + */ + public function applyBasicAuth(): void { + $credentials = $this->resolveBasicAuth(); + if ($credentials === NULL) { + return; + } + + try { + $this->getSession()->setBasicAuth($credentials['username'], $credentials['password']); + } + catch (UnsupportedDriverActionException) { + // The active driver cannot set basic auth headers (a JavaScript driver, + // for example); those receive credentials via the 'base_url' userinfo. + } + } + + /** + * Returns the logout element from the page. + */ + public function getLogoutElement(): ?NodeElement { + return $this->getSession()->getPage()->findLink($this->getDrupalText('log_out')); + } + + /** + * Resolves the HTTP Basic authentication credentials to apply. + * + * Credentials are derived from the 'base_url' userinfo + * ('http://user:pass@host'). + * + * @return array{username: string, password: string}|null + * The resolved credentials, or NULL when the 'base_url' carries no + * username. + */ + protected function resolveBasicAuth(): ?array { + $base_url = (string) $this->getMinkParameter('base_url'); + $user = parse_url($base_url, PHP_URL_USER); + if (is_string($user) && $user !== '') { + $pass = parse_url($base_url, PHP_URL_PASS); + + return [ + // Userinfo is RFC 3986 encoded, where '+' is a literal plus and + // spaces are '%20', so decode with rawurldecode() rather than + // urldecode() (which would turn a literal '+' into a space). + 'username' => rawurldecode($user), + 'password' => is_string($pass) ? rawurldecode($pass) : '', + ]; + } + + return NULL; + } + + /** + * Returns the login element from the page. + */ + protected function getLoginElement(DocumentElement $element): ?NodeElement { + return $element->findButton($this->getDrupalText('log_in')); + } + + /** + * Returns the logout confirm element from the page. + */ + protected function getLogoutConfirmElement(DocumentElement $element): ?NodeElement { + return $element->findButton($this->getDrupalText('log_out')); + } + + /** + * Logs in on the backend driver if it supports authentication. + */ + protected function backendLogin(EntityStubInterface $user): void { + $driver = $this->driverManager->getDriver(); + if ($driver instanceof AuthenticationCapabilityInterface) { + $driver->login($user); + } + } + + /** + * Logs out on the backend driver if it supports authentication. + */ + protected function backendLogout(): void { + $driver = $this->driverManager->getDriver(); + if ($driver instanceof AuthenticationCapabilityInterface) { + $driver->logout(); + } + } + +} diff --git a/src/Behat/Manager/AuthenticationManagerInterface.php b/src/Behat/Manager/AuthenticationManagerInterface.php new file mode 100644 index 00000000..c1d0186a --- /dev/null +++ b/src/Behat/Manager/AuthenticationManagerInterface.php @@ -0,0 +1,32 @@ + + */ + protected array $drivers = []; + + /** + * Behat environment. + */ + protected ?Environment $environment = NULL; + + /** + * Initializes the driver manager. + * + * @param array $drivers + * Drivers to register, keyed by name. + */ + public function __construct(array $drivers = []) { + foreach ($drivers as $name => $driver) { + $this->registerDriver($name, $driver); + } + } + + /** + * {@inheritdoc} + */ + public function registerDriver(string $name, DriverInterface $driver): void { + $name = strtolower($name); + $this->drivers[$name] = $driver; + } + + /** + * {@inheritdoc} + */ + public function getDriver(?string $name = NULL): DriverInterface { + $name = $name === NULL ? $this->defaultDriverName : strtolower($name); + + if ($name === NULL) { + throw new \InvalidArgumentException('Specify a Drupal driver to get.'); + } + + if (!isset($this->drivers[$name])) { + throw new \InvalidArgumentException(sprintf('Driver "%s" is not registered', $name)); + } + + $driver = $this->drivers[$name]; + + if (!$driver->isBootstrapped()) { + $driver->bootstrap(); + } + + return $driver; + } + + /** + * {@inheritdoc} + */ + public function getDrivers(): array { + return $this->drivers; + } + + /** + * {@inheritdoc} + */ + public function setDefaultDriverName(string $name): void { + $name = strtolower($name); + + if (!isset($this->drivers[$name])) { + throw new \InvalidArgumentException(sprintf('Driver "%s" is not registered.', $name)); + } + + $this->defaultDriverName = $name; + } + + /** + * {@inheritdoc} + */ + public function getEnvironment(): ?Environment { + return $this->environment; + } + + /** + * {@inheritdoc} + */ + public function setEnvironment(Environment $environment): void { + $this->environment = $environment; + } + +} diff --git a/src/Behat/Manager/DriverManagerInterface.php b/src/Behat/Manager/DriverManagerInterface.php new file mode 100644 index 00000000..56061a0a --- /dev/null +++ b/src/Behat/Manager/DriverManagerInterface.php @@ -0,0 +1,69 @@ + + * The drivers, keyed by their lowercased name. + */ + public function getDrivers(): array; + + /** + * Sets the default driver name. + * + * @param string $name + * Default driver name to set. + * + * @throws \InvalidArgumentException + * Thrown when the driver is not registered. + */ + public function setDefaultDriverName(string $name): void; + + /** + * Returns the Behat environment. + */ + public function getEnvironment(): ?Environment; + + /** + * Sets the Behat environment. + */ + public function setEnvironment(Environment $environment): void; + +} diff --git a/src/Behat/Manager/FastLogoutInterface.php b/src/Behat/Manager/FastLogoutInterface.php new file mode 100644 index 00000000..80380d16 --- /dev/null +++ b/src/Behat/Manager/FastLogoutInterface.php @@ -0,0 +1,19 @@ +driver->mailStartCollecting(); + $this->clearMail(); + } + + /** + * {@inheritdoc} + */ + public function stopCollectingMail(): void { + $this->driver->mailStopCollecting(); + } + + /** + * {@inheritdoc} + */ + public function enableMail(): void { + $this->stopCollectingMail(); + } + + /** + * {@inheritdoc} + */ + public function disableMail(): void { + $this->startCollectingMail(); + } + + /** + * {@inheritdoc} + */ + public function getMail(): array { + return $this->driver->mailGet(); + } + + /** + * {@inheritdoc} + */ + public function clearMail(): void { + $this->driver->mailClear(); + } + +} diff --git a/src/Behat/Manager/MailManagerInterface.php b/src/Behat/Manager/MailManagerInterface.php new file mode 100644 index 00000000..f60d67cf --- /dev/null +++ b/src/Behat/Manager/MailManagerInterface.php @@ -0,0 +1,47 @@ +> + * An array of collected emails. Each item is a Drupal mail message + * array as produced by 'MailInterface::mail()' - the keys include + * 'to', 'subject', 'body', 'headers', etc. + */ + public function getMail(): array; + + /** + * Empty the store of collected mail. + */ + public function clearMail(): void; + +} diff --git a/src/Behat/Manager/UserManager.php b/src/Behat/Manager/UserManager.php new file mode 100644 index 00000000..1b656dcd --- /dev/null +++ b/src/Behat/Manager/UserManager.php @@ -0,0 +1,120 @@ + + */ + protected array $users = []; + + /** + * {@inheritdoc} + */ + public function getCurrentUser(): EntityStubInterface|false { + return $this->user; + } + + /** + * {@inheritdoc} + */ + public function setCurrentUser(EntityStubInterface|false $user): void { + $this->user = $user; + } + + /** + * {@inheritdoc} + */ + public function addUser(EntityStubInterface $user): void { + $name = (string) $user->getValue('name'); + $this->users[$name] = $user; + } + + /** + * {@inheritdoc} + */ + public function removeUser(string $userName): void { + unset($this->users[$userName]); + } + + /** + * {@inheritdoc} + */ + public function getUser(string $userName): EntityStubInterface { + if (!isset($this->users[$userName])) { + throw new \InvalidArgumentException(sprintf('No user with %s name is registered with the driver.', $userName)); + } + + return $this->users[$userName]; + } + + /** + * {@inheritdoc} + */ + public function getUsers(): array { + return $this->users; + } + + /** + * {@inheritdoc} + */ + public function clearUsers(): void { + $this->user = FALSE; + $this->users = []; + } + + /** + * {@inheritdoc} + */ + public function hasUsers(): bool { + return $this->users !== []; + } + + /** + * {@inheritdoc} + */ + public function currentUserIsAnonymous(): bool { + return $this->user === FALSE; + } + + /** + * {@inheritdoc} + */ + public function currentUserHasRole(string $role): bool { + if (!$this->user instanceof EntityStubInterface) { + return FALSE; + } + + $current_role = $this->user->getValue('role'); + + if ($current_role === NULL || $current_role === '') { + return FALSE; + } + + $held = array_map(trim(...), explode(',', (string) $current_role)); + + foreach (explode(',', $role) as $wanted) { + if (!in_array(trim($wanted), $held, TRUE)) { + return FALSE; + } + } + + return TRUE; + } + +} diff --git a/src/Behat/Manager/UserManagerInterface.php b/src/Behat/Manager/UserManagerInterface.php new file mode 100644 index 00000000..dbd42e8f --- /dev/null +++ b/src/Behat/Manager/UserManagerInterface.php @@ -0,0 +1,101 @@ + + * An array of user stubs keyed by user name. + */ + public function getUsers(): array; + + /** + * Returns the user with the given user name. + * + * @param string $userName + * The name of the user to return. + * + * @return \DrevOps\BehatSteps\Driver\Entity\EntityStubInterface + * The user stub. + * + * @throws \InvalidArgumentException + * Thrown when the user with the given name does not exist. + */ + public function getUser(string $userName): EntityStubInterface; + + /** + * Clears the list of users that were created in the test. + */ + public function clearUsers(): void; + + /** + * Returns whether any users were created in the test. + */ + public function hasUsers(): bool; + + /** + * Returns whether the current user is anonymous. + */ + public function currentUserIsAnonymous(): bool; + + /** + * Checks whether the current user holds the given roles. + * + * Both the query and the user's own role value are comma-separated lists, + * and surrounding whitespace on either side is ignored. + * + * @param string $role + * A single role, or several roles as one comma-separated string. + * + * @return bool + * TRUE when the current user holds every role named in the query. + */ + public function currentUserHasRole(string $role): bool; + +} diff --git a/src/Behat/MinkAwareTrait.php b/src/Behat/MinkAwareTrait.php new file mode 100644 index 00000000..ee4745e3 --- /dev/null +++ b/src/Behat/MinkAwareTrait.php @@ -0,0 +1,129 @@ + + */ + protected array $minkParameters = []; + + /** + * Sets the Mink sessions manager. + */ + public function setMink(Mink $mink): void { + $this->mink = $mink; + } + + /** + * Returns the Mink sessions manager. + */ + public function getMink(): Mink { + return $this->mink; + } + + /** + * Returns the Mink session. + * + * @param string|null $name + * The name of the session to return. If omitted the active session will + * be returned. + */ + public function getSession(?string $name = NULL): Session { + return $this->getMink()->getSession($name); + } + + /** + * Returns the parameters provided for Mink. + * + * @return array + * An array of Mink parameters. + */ + public function getMinkParameters(): array { + return $this->minkParameters; + } + + /** + * Sets parameters provided for Mink. + * + * @param array $parameters + * The Mink parameters to set. + */ + public function setMinkParameters(array $parameters): void { + $this->minkParameters = $parameters; + } + + /** + * Returns a specific Mink parameter. + */ + public function getMinkParameter(string $name): mixed { + return $this->minkParameters[$name] ?? NULL; + } + + /** + * Applies the given parameter to the Mink configuration. + * + * The value applies only within the class using this trait. + */ + public function setMinkParameter(string $name, mixed $value): void { + $this->minkParameters[$name] = $value; + } + + /** + * Returns the Mink session assertion tool. + * + * @param string|null $name + * The name of the session to return. If omitted the active session will + * be returned. + */ + public function assertSession(?string $name = NULL): WebAssert { + return $this->getMink()->assertSession($name); + } + + /** + * Visits the provided relative path using the provided or default session. + */ + public function visitPath(string $path, ?string $session_name = NULL): void { + $this->getSession($session_name)->visit($this->locatePath($path)); + } + + /** + * Locates a URL, based on the provided path. + * + * Override to provide a custom routing mechanism. + */ + public function locatePath(string $path): string { + // Only a full HTTP scheme makes the path absolute, so a relative path + // that merely starts with the same letters still resolves against + // 'base_url'. + if (preg_match('#^https?://#i', $path) === 1) { + return $path; + } + + return rtrim((string) $this->getMinkParameter('base_url'), '/') . '/' . ltrim($path, '/'); + } + +} diff --git a/src/Behat/ParametersAwareInterface.php b/src/Behat/ParametersAwareInterface.php new file mode 100644 index 00000000..d9604d9b --- /dev/null +++ b/src/Behat/ParametersAwareInterface.php @@ -0,0 +1,31 @@ + $parameters + * The extension parameters. + */ + public function setParameters(array $parameters): void; + + /** + * Returns a specific extension parameter. + * + * @param string $name + * Parameter name. + * + * @return mixed + * The value, or NULL if the parameter is not set. + */ + public function getParameter(string $name): mixed; + +} diff --git a/src/Behat/ParametersTrait.php b/src/Behat/ParametersTrait.php new file mode 100644 index 00000000..1c0430e0 --- /dev/null +++ b/src/Behat/ParametersTrait.php @@ -0,0 +1,121 @@ + + */ + protected array $parameters = []; + + /** + * Sets parameters provided by the extension. + * + * @param array $parameters + * The parameters to set. + */ + public function setParameters(array $parameters): void { + $this->parameters = $parameters; + } + + /** + * Returns a specific extension parameter. + * + * @param string $name + * Parameter name. + * + * @return mixed + * The value, or NULL if the parameter does not exist. + */ + public function getParameter(string $name): mixed { + return $this->parameters[$name] ?? NULL; + } + + /** + * Returns a specific Drupal text value. + * + * @param string $name + * Text value name, such as 'log_out', which corresponds to the default + * 'Log out' link text. + * + * @return string + * The text value. + * + * @throws \RuntimeException + * Thrown when the text is not present in the list of parameters. + */ + public function getDrupalText(string $name): string { + $text = $this->getParameter('text'); + if (!isset($text[$name])) { + throw new \RuntimeException(sprintf('No such Drupal string: %s', $name)); + } + + return $text[$name]; + } + + /** + * Returns a specific CSS selector. + * + * @param string $name + * The name of the CSS selector. + * + * @return string + * The CSS selector. + * + * @throws \RuntimeException + * Thrown when the selector is not present in the list of parameters. + */ + public function getDrupalSelector(string $name): string { + $selectors = $this->getParameter('selectors'); + if (!isset($selectors[$name])) { + throw new \RuntimeException(sprintf('No such selector configured: %s', $name)); + } + + return $selectors[$name]; + } + + /** + * Returns a mapped value by its key. + * + * Keys are unique across every configured 'mappings' group, so the group + * a key lives in is irrelevant to the lookup. + * + * @param string $name + * The mapping key. + * + * @return string + * The mapped value. + * + * @throws \RuntimeException + * Thrown when the key is not present in the configured mappings. + */ + public function getMapping(string $name): string { + $mappings = $this->getParameter('mappings'); + if (!isset($mappings[$name])) { + throw new \RuntimeException(sprintf('No such mapping: %s', $name)); + } + + return $mappings[$name]; + } + +} diff --git a/src/Behat/Selector/RegionSelector.php b/src/Behat/Selector/RegionSelector.php new file mode 100644 index 00000000..c9ebf1f9 --- /dev/null +++ b/src/Behat/Selector/RegionSelector.php @@ -0,0 +1,59 @@ +find("region", "Header")', and Mink dispatches here. + * + * Regions are a generic page concept, so this selector has no Drupal API + * dependency and works against any HTML page. + */ +class RegionSelector implements SelectorInterface { + + /** + * Constructs a RegionSelector. + * + * @param \Behat\Mink\Selector\CssSelector $cssSelector + * The CSS selector that performs the actual CSS-to-XPath translation. + * @param array $regions + * Map of region names to CSS selectors, sourced from the extension's + * 'regions' configuration. + */ + public function __construct( + protected readonly CssSelector $cssSelector, + protected array $regions, + ) { + } + + /** + * Translates a region name into XPath. + * + * @param string|array $locator + * The region name to translate. + * + * @return string + * The XPath for the region. + * + * @throws \InvalidArgumentException + * When the name matches no configured region. + */ + // phpcs:ignore Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps + public function translateToXPath($locator): string { + if (!is_string($locator) || !isset($this->regions[$locator])) { + throw new \InvalidArgumentException(sprintf('The "%s" region isn\'t configured!', is_string($locator) ? $locator : gettype($locator))); + } + + return $this->cssSelector->translateToXPath($this->regions[$locator]); + } + +} diff --git a/src/Behat/ServiceContainer/BehatStepsExtension.php b/src/Behat/ServiceContainer/BehatStepsExtension.php new file mode 100644 index 00000000..58b3cf94 --- /dev/null +++ b/src/Behat/ServiceContainer/BehatStepsExtension.php @@ -0,0 +1,390 @@ +load('services.yml'); + $container->setParameter('behat_steps.default_driver', $config['default_driver']); + + $this->loadParameters($container, $config); + + $this->loadBlackbox($loader); + $this->loadDrupal($loader, $container, $config); + $this->loadDrush($loader, $container, $config); + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void { + $this->processDriverPass($container); + $this->processClassGenerator($container); + } + + /** + * {@inheritdoc} + */ + public function configure(ArrayNodeDefinition $builder): void { + // @formatter:off + // phpcs:disable + $builder + ->children() + ->scalarNode('default_driver') + ->defaultValue('blackbox') + ->info('Use "blackbox" to test remote site. See "api_driver" for easier integration.') + ->end() + ->scalarNode('api_driver') + ->defaultValue('drush') + ->info('Bootstraps drupal through "drupal" or "drush".') + ->end() + ->scalarNode('drush_driver') + ->defaultValue('drush') + ->end() + ->scalarNode('login_field') + ->defaultValue('name') + ->info('User entity property submitted as the login value. Defaults to "name". Set to "mail" for sites that authenticate by email, or any other user property.') + ->end() + ->arrayNode('regions') + ->info("Map of named regions to CSS selectors. Region steps such as 'I press :button in the :region region' resolve against this map." . PHP_EOL + . ' My region: "#css-selector"' . PHP_EOL + . ' Content: "#main .region-content"' . PHP_EOL + . ' Right sidebar: "#sidebar-second"' . PHP_EOL) + ->useAttributeAsKey('key') + ->prototype('scalar')->end() + ->end() + ->arrayNode('text') + ->info( + 'Text strings, such as Log out or the Username field can be altered via behat.yml if they vary from the default values.' . PHP_EOL + . ' login_url: "/user"' . PHP_EOL + . ' logout_url: "/user/logout"' . PHP_EOL + . ' logout_confirm_url: "/user/logout/confirm"' . PHP_EOL + . ' log_out: "Sign out"' . PHP_EOL + . ' log_in: "Sign in"' . PHP_EOL + . ' password_field: "Enter your password"' . PHP_EOL + . ' username_field: "Nickname"' + ) + ->ignoreExtraKeys(FALSE) + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('login_url') + ->defaultValue('/user') + ->end() + ->scalarNode('logout_url') + ->defaultValue('/user/logout') + ->end() + ->scalarNode('logout_confirm_url') + ->defaultValue('/user/logout/confirm') + ->end() + ->scalarNode('log_in') + ->defaultValue('Log in') + ->end() + ->scalarNode('log_out') + ->defaultValue('Log out') + ->end() + ->scalarNode('password_field') + ->defaultValue('Password') + ->end() + ->scalarNode('username_field') + ->defaultValue('Username') + ->end() + ->end() + ->end() + ->integerNode('login_wait') + ->min(0) + ->defaultValue(0) + ->info('Maximum seconds to wait for post-login DOM signals (URL change, body render, logged-in selector, logout link). Set to 0 to disable waiting.') + ->end() + ->integerNode('ajax_timeout') + ->min(0) + ->defaultValue(5) + ->info('Maximum time (in seconds) to wait for AJAX calls to complete.') + ->end() + ->arrayNode('selectors') + ->ignoreExtraKeys(FALSE) + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('messages') + ->ignoreExtraKeys(FALSE) + ->children() + ->scalarNode('default')->end() + ->scalarNode('error')->end() + ->scalarNode('success')->end() + ->scalarNode('warning')->end() + ->end() + ->end() + ->scalarNode('login_form_selector') + ->defaultValue('form#user-login,form#user-login-form') + ->end() + ->scalarNode('logged_in_selector') + ->defaultValue('body.logged-in,body.user-logged-in') + ->end() + ->end() + ->end() + ->arrayNode('mappings') + ->info('Named value mappings grouped for organisation. A "{{ Key }}" token in any step argument or table cell is replaced with the mapped value before the step runs; whitespace inside the braces is ignored, so "{{ Key }}" and "{{Key}}" are equivalent. Group names are organisational only - a key must be unique across all groups.' . PHP_EOL + . ' paths:' . PHP_EOL + . ' User Registration: "/user/register"' . PHP_EOL + . ' User Login: "/user/login"' . PHP_EOL) + ->useAttributeAsKey('group') + ->prototype('array') + ->useAttributeAsKey('key') + ->prototype('scalar')->end() + ->end() + ->end() + // Drupal drivers. + ->arrayNode('blackbox')->end() + ->arrayNode('drupal') + ->children() + ->scalarNode('drupal_root') + ->isRequired() + ->cannotBeEmpty() + ->info('Path to the Drupal root the in-process driver bootstraps.') + ->end() + ->end() + ->end() + ->arrayNode('drush') + ->children() + ->scalarNode('alias')->end() + ->scalarNode('binary')->defaultValue('vendor/bin/drush')->end() + ->scalarNode('root')->end() + ->scalarNode('global_options')->end() + ->end() + ->end() + ->end() + ->end(); + // phpcs:enable + // @formatter:on + } + + /** + * Load test parameters. + * + * Exposes the configured region map under the 'behat_steps.regions' container + * parameter and surfaces it through the 'region' Mink selector. + * + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * The container builder. + * @param array $config + * The extension configuration. + */ + protected function loadParameters(ContainerBuilder $container, array $config): void { + $regions = $config['regions'] ?? []; + + // Mirror the map into the config so the 'behat_steps.parameters' and + // 'behat_steps.regions' container parameters always expose the same value, + // even when the optional 'regions' key was omitted from behat.yml. + $config['regions'] = $regions; + + // Flatten the grouped mappings to a single key => value map that + // contexts resolve '{{ Key }}' tokens against. Groups are only a way + // to organise the configuration, so a key must be unique across them. + $config['mappings'] = $this->flattenMappings($config['mappings'] ?? []); + + $container->setParameter('behat_steps.parameters', $config); + $container->setParameter('behat_steps.regions', $regions); + } + + /** + * Flattens grouped mappings into a single key => value map. + * + * @param array> $grouped + * Mappings as configured: group name => (key => value). + * + * @return array + * The flattened key => value map. + * + * @throws \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException + * When the same key appears in more than one group, which would make the + * bare-key '{{ Key }}' token ambiguous. + */ + protected function flattenMappings(array $grouped): array { + $flat = []; + $groups = []; + + foreach ($grouped as $group => $entries) { + foreach ($entries as $key => $value) { + if (isset($groups[$key])) { + throw new InvalidConfigurationException(sprintf('Duplicate mapping key "%s" found in groups "%s" and "%s" under "%s: mappings:". Mapping keys must be unique across all groups.', $key, $groups[$key], $group, self::CONFIG_KEY)); + } + + $groups[$key] = $group; + $flat[$key] = (string) $value; + } + } + + return $flat; + } + + /** + * Load the blackbox driver. + */ + protected function loadBlackbox(FileLoader $loader): void { + // The blackbox driver is the fallback for scenarios that select no other, + // so it is always registered. + $loader->load('drivers/blackbox.yml'); + } + + /** + * Load the Drupal driver. + * + * @param \Symfony\Component\DependencyInjection\Loader\FileLoader $loader + * The file loader. + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * The container builder. + * @param array $config + * The extension configuration. + */ + protected function loadDrupal(FileLoader $loader, ContainerBuilder $container, array $config): void { + if (isset($config['drupal'])) { + $loader->load('drivers/drupal.yml'); + $container->setParameter('behat_steps.driver.drupal.drupal_root', $config['drupal']['drupal_root']); + } + } + + /** + * Load the Drush driver. + * + * @param \Symfony\Component\DependencyInjection\Loader\FileLoader $loader + * The file loader. + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * The container builder. + * @param array $config + * The extension configuration. + * + * @throws \RuntimeException + * When neither a Drush alias nor a Drupal root is configured. + */ + protected function loadDrush(FileLoader $loader, ContainerBuilder $container, array $config): void { + if (isset($config['drush'])) { + $loader->load('drivers/drush.yml'); + if (!isset($config['drush']['alias']) && !isset($config['drush']['root'])) { + throw new \RuntimeException('Drush `alias` or `root` path is required for the Drush driver.'); + } + $config['drush']['alias'] ??= FALSE; + $container->setParameter('behat_steps.driver.drush.alias', $config['drush']['alias']); + + $config['drush']['binary'] ??= 'vendor/bin/drush'; + $config['drush']['binary'] = self::resolveBinaryPath($config['drush']['binary']); + $container->setParameter('behat_steps.driver.drush.binary', $config['drush']['binary']); + + $config['drush']['root'] ??= FALSE; + $container->setParameter('behat_steps.driver.drush.root', $config['drush']['root']); + + $this->setDrushOptions($container, $config); + } + } + + /** + * Resolve a relative binary path to an absolute path. + * + * Probes the current working directory and its parent to locate the binary. + * This ensures the path remains valid after the Drupal API driver changes + * the working directory to DRUPAL_ROOT via chdir(). + * + * Absolute paths and binaries without a directory separator (bare commands + * like 'drush' that resolve via $PATH) are returned as-is. + */ + public static function resolveBinaryPath(string $binary): string { + if (str_starts_with($binary, '/')) { + return $binary; + } + + // Bare command names (no directory separator) resolve via $PATH. + if (!str_contains($binary, '/')) { + return $binary; + } + + $cwd = (string) getcwd(); + + $candidate = $cwd . '/' . $binary; + if (file_exists($candidate)) { + return $candidate; + } + + // Probe the parent directory, which covers a working directory one level + // deep such as a Drupal root inside a project. + $candidate = dirname($cwd) . '/' . $binary; + if (file_exists($candidate)) { + return $candidate; + } + + return $binary; + } + + /** + * Set global drush arguments. + * + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * The container builder. + * @param array $config + * The extension configuration. + */ + protected function setDrushOptions(ContainerBuilder $container, array $config): void { + if (isset($config['drush']['global_options'])) { + $definition = $container->getDefinition('behat_steps.driver.drush'); + $definition->addMethodCall('setArguments', [$config['drush']['global_options']]); + } + } + + /** + * Process the driver pass. + */ + protected function processDriverPass(ContainerBuilder $container): void { + $driver_pass = new DriverPass(); + $driver_pass->process($container); + } + + /** + * Switch to custom class generator. + * + * Behat collects generators by tag before an activated extension's + * 'process()' runs, and it collects them as references to a service id, so + * replacing the definition behind that id swaps the class in place. + */ + protected function processClassGenerator(ContainerBuilder $container): void { + $definition = new Definition(ClassGenerator::class); + $container->setDefinition(ContextExtension::CLASS_GENERATOR_TAG . '.simple', $definition); + } + +} diff --git a/src/Behat/ServiceContainer/DriverPass.php b/src/Behat/ServiceContainer/DriverPass.php new file mode 100644 index 00000000..3ee01161 --- /dev/null +++ b/src/Behat/ServiceContainer/DriverPass.php @@ -0,0 +1,51 @@ +hasDefinition('behat_steps.driver_manager')) { + return; + } + + $manager_definition = $container->getDefinition('behat_steps.driver_manager'); + + foreach ($container->findTaggedServiceIds('behat_steps.driver') as $id => $attributes) { + foreach ($attributes as $attribute) { + if (isset($attribute['alias']) && $name = $attribute['alias']) { + $manager_definition->addMethodCall('registerDriver', [$name, new Reference($id)]); + } + } + + // The Drupal driver takes a single Core via setCore(). Resolve the + // first service tagged 'behat_steps.core' and inject it. + if ($id !== 'behat_steps.driver.drupal') { + continue; + } + + $core_ids = array_keys($container->findTaggedServiceIds('behat_steps.core')); + + if ($core_ids === []) { + continue; + } + + $container->getDefinition($id)->addMethodCall('setCore', [new Reference($core_ids[0])]); + } + + $manager_definition->addMethodCall('setDefaultDriverName', [$container->getParameter('behat_steps.default_driver')]); + } + +} diff --git a/src/Behat/ServiceContainer/config/drivers/blackbox.yml b/src/Behat/ServiceContainer/config/drivers/blackbox.yml new file mode 100644 index 00000000..9910695f --- /dev/null +++ b/src/Behat/ServiceContainer/config/drivers/blackbox.yml @@ -0,0 +1,8 @@ +parameters: + behat_steps.driver.blackbox.class: DrevOps\BehatSteps\Driver\BlackboxDriver + +services: + behat_steps.driver.blackbox: + class: "%behat_steps.driver.blackbox.class%" + tags: + - { name: behat_steps.driver, alias: blackbox } diff --git a/src/Behat/ServiceContainer/config/drivers/drupal.yml b/src/Behat/ServiceContainer/config/drivers/drupal.yml new file mode 100644 index 00000000..8f337b48 --- /dev/null +++ b/src/Behat/ServiceContainer/config/drivers/drupal.yml @@ -0,0 +1,27 @@ +parameters: + behat_steps.driver.drupal.class: DrevOps\BehatSteps\Driver\DrupalDriver + + # Random generator. + behat_steps.random.class: Drupal\Component\Utility\Random + + # Core controller. + behat_steps.driver.core.class: DrevOps\BehatSteps\Driver\Core\Core + +services: + behat_steps.driver.random: + class: "%behat_steps.random.class%" + behat_steps.driver.drupal: + class: "%behat_steps.driver.drupal.class%" + arguments: + - "%behat_steps.driver.drupal.drupal_root%" + - "%mink.base_url%" + tags: + - { name: behat_steps.driver, alias: drupal } + behat_steps.driver.core: + class: "%behat_steps.driver.core.class%" + tags: + - { name: behat_steps.core } + arguments: + - "%behat_steps.driver.drupal.drupal_root%" + - "%mink.base_url%" + - "@behat_steps.driver.random" diff --git a/src/Behat/ServiceContainer/config/drivers/drush.yml b/src/Behat/ServiceContainer/config/drivers/drush.yml new file mode 100644 index 00000000..c5d6393e --- /dev/null +++ b/src/Behat/ServiceContainer/config/drivers/drush.yml @@ -0,0 +1,18 @@ +parameters: + behat_steps.driver.drush.class: DrevOps\BehatSteps\Driver\DrushDriver + + # Random generator. + behat_steps.random.class: Drupal\Component\Utility\Random + +services: + behat_steps.driver.random: + class: "%behat_steps.random.class%" + behat_steps.driver.drush: + class: "%behat_steps.driver.drush.class%" + arguments: + - "%behat_steps.driver.drush.alias%" + - "%behat_steps.driver.drush.root%" + - "%behat_steps.driver.drush.binary%" + - "@behat_steps.driver.random" + tags: + - { name: behat_steps.driver, alias: drush } diff --git a/src/Behat/ServiceContainer/config/services.yml b/src/Behat/ServiceContainer/config/services.yml new file mode 100644 index 00000000..e5a6552b --- /dev/null +++ b/src/Behat/ServiceContainer/config/services.yml @@ -0,0 +1,54 @@ +parameters: + behat_steps.driver_manager.class: DrevOps\BehatSteps\Behat\Manager\DriverManager + behat_steps.authentication_manager.class: DrevOps\BehatSteps\Behat\Manager\AuthenticationManager + behat_steps.user_manager.class: DrevOps\BehatSteps\Behat\Manager\UserManager + behat_steps.context.initializer.class: DrevOps\BehatSteps\Behat\Context\Initializer\DriverAwareInitializer + behat_steps.context.attribute_reader.class: DrevOps\BehatSteps\Behat\Context\Attribute\HookAttributeReader + behat_steps.listener.driver.class: DrevOps\BehatSteps\Behat\Listener\DriverListener + behat_steps.region_selector.class: DrevOps\BehatSteps\Behat\Selector\RegionSelector + behat_steps.parameters: {} + behat_steps.regions: {} + +services: + behat_steps.driver_manager: + class: "%behat_steps.driver_manager.class%" + arguments: + - {} + behat_steps.authentication_manager: + class: "%behat_steps.authentication_manager.class%" + arguments: + - "@mink" + - "@behat_steps.user_manager" + - "@behat_steps.driver_manager" + - "%mink.parameters%" + - "%behat_steps.parameters%" + behat_steps.user_manager: + class: "%behat_steps.user_manager.class%" + behat_steps.context.initializer: + class: "%behat_steps.context.initializer.class%" + arguments: + - "@behat_steps.driver_manager" + - "%behat_steps.parameters%" + - "@hook.dispatcher" + - "@behat_steps.authentication_manager" + - "@behat_steps.user_manager" + tags: + - { name: context.initializer } + behat_steps.context.attribute_reader: + class: "%behat_steps.context.attribute_reader.class%" + tags: + - { name: context.attribute_reader } + behat_steps.listener.driver: + class: "%behat_steps.listener.driver.class%" + arguments: + - "@behat_steps.driver_manager" + - "%behat_steps.parameters%" + tags: + - { name: event_dispatcher.subscriber, priority: 0 } + behat_steps.region_selector: + class: "%behat_steps.region_selector.class%" + arguments: + - "@mink.selector.css" + - "%behat_steps.regions%" + tags: + - { name: mink.selector, alias: region } diff --git a/tests/phpunit/src/Kernel/Behat/Context/RawContextVocabularyKernelTest.php b/tests/phpunit/src/Kernel/Behat/Context/RawContextVocabularyKernelTest.php new file mode 100644 index 00000000..4ffd707e --- /dev/null +++ b/tests/phpunit/src/Kernel/Behat/Context/RawContextVocabularyKernelTest.php @@ -0,0 +1,103 @@ + + */ + protected static $modules = ['taxonomy', 'text', 'user', 'field', 'system']; + + /** + * The context under test. + */ + protected TestableRawContext $context; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + Vocabulary::create(['vid' => 'tags', 'name' => 'Tags'])->save(); + + $this->context = new TestableRawContext(); + } + + /** + * Tests that a machine name is returned untouched. + */ + public function testMachineNameResolvesToItself(): void { + $this->assertSame('tags', $this->context->callResolveVocabularyMachineName('tags')); + } + + /** + * Tests that a human label resolves to the vocabulary's machine name. + */ + public function testLabelResolvesToItsMachineName(): void { + $this->assertSame('tags', $this->context->callResolveVocabularyMachineName('Tags')); + } + + /** + * Tests that an unknown identifier is handed back for the driver to reject. + */ + public function testAnUnknownIdentifierIsReturnedUnchanged(): void { + $this->assertSame('Unknown', $this->context->callResolveVocabularyMachineName('Unknown')); + } + + /** + * Tests that term creation resolves the label before calling the driver. + */ + public function testTermCreationResolvesTheVocabularyLabel(): void { + $stub = new EntityStub('taxonomy_term', 'tags', ['name' => 'A term', 'vocabulary_machine_name' => 'Tags']); + + $driver = $this->createMockForIntersectionOfInterfaces([DriverInterface::class, ContentCapabilityInterface::class]); + $driver->expects($this->once())->method('termCreate')->willReturnCallback(function (EntityStub $received) use ($stub): EntityStub { + $this->assertSame('tags', $received->getValue('vocabulary_machine_name')); + + return $stub; + }); + + $environment = $this->createMock(Environment::class); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($driver); + $driver_manager->method('getEnvironment')->willReturn($environment); + + $this->context->setDriverManager($driver_manager); + $this->context->setDispatcher(new HookDispatcher(new HookRepository(new EnvironmentManager()), new CallCenter())); + + $this->context->termCreate($stub); + + $this->assertSame('tags', $stub->getValue('vocabulary_machine_name')); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Context/Attribute/HookAttributeReaderTest.php b/tests/phpunit/src/Unit/Behat/Context/Attribute/HookAttributeReaderTest.php new file mode 100644 index 00000000..d52f014b --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Context/Attribute/HookAttributeReaderTest.php @@ -0,0 +1,95 @@ +read('beforeNode'); + + $this->assertCount(1, $callees); + $this->assertInstanceOf(BeforeNodeCreate::class, $callees[0]); + $this->assertSame(NodeScope::BEFORE, $callees[0]->getScopeName()); + $this->assertSame('BeforeNodeCreate', $callees[0]->getName()); + } + + public function testStaticHookIsCalledThroughItsClass(): void { + $callees = $this->read('beforeNode'); + + $this->assertSame([HookedContext::class, 'beforeNode'], $callees[0]->getCallable()); + } + + public function testAnInstanceHookResolvesToItsContextMethod(): void { + $callees = $this->read('afterNode'); + + $this->assertCount(1, $callees); + $this->assertInstanceOf(AfterNodeCreate::class, $callees[0]); + + // Behat 3 takes the '[class, method]' pair and Behat 4 wraps an instance + // method in a late-bound callable, so assert the method the callee + // resolves to rather than the shape it is carried in. + $reflection = $callees[0]->getReflection(); + + $this->assertInstanceOf(\ReflectionMethod::class, $reflection); + $this->assertSame(HookedContext::class, $reflection->getDeclaringClass()->getName()); + $this->assertSame('afterNode', $reflection->getName()); + } + + public function testTheFilterStringIsCarriedOntoTheCall(): void { + $callees = $this->read('filtered'); + + $this->assertInstanceOf(AfterEntityCreate::class, $callees[0]); + $this->assertSame('@api', $callees[0]->getFilterString()); + $this->assertSame(EntityScopeInterface::AFTER, $callees[0]->getScopeName()); + } + + public function testMethodMayCarryMoreThanOneHook(): void { + $callees = $this->read('both'); + + $this->assertCount(2, $callees); + $this->assertInstanceOf(BeforeNodeCreate::class, $callees[0]); + $this->assertInstanceOf(AfterNodeCreate::class, $callees[1]); + } + + public function testAnUnrelatedBehatHookIsIgnored(): void { + $this->assertSame([], $this->read('unrelated')); + } + + public function testEntityHookWithoutCallClassIsSkipped(): void { + $this->assertSame([], $this->read('unmapped')); + } + + public function testMethodWithoutAttributesYieldsNothing(): void { + $this->assertSame([], $this->read('plain')); + } + + /** + * Reads the callees a fixture context method declares. + * + * @param string $method + * The fixture method to reflect. + * + * @return array + * The callees the reader produced. + */ + protected function read(string $method): array { + return (new HookAttributeReader())->readCallees(HookedContext::class, new \ReflectionMethod(HookedContext::class, $method)); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Context/Initializer/DriverAwareInitializerTest.php b/tests/phpunit/src/Unit/Behat/Context/Initializer/DriverAwareInitializerTest.php new file mode 100644 index 00000000..2b0e0706 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Context/Initializer/DriverAwareInitializerTest.php @@ -0,0 +1,74 @@ + 'drush']; + + public function testPlainContextIsLeftAlone(): void { + $context = $this->createMock(Context::class); + + $this->createInitializer()->initializeContext($context); + + $this->assertInstanceOf(Context::class, $context); + } + + public function testParametersAwareContextReceivesOnlyParameters(): void { + /** @var \Behat\Behat\Context\Context&\DrevOps\BehatSteps\Behat\ParametersAwareInterface&\PHPUnit\Framework\MockObject\MockObject $context */ + $context = $this->createMockForIntersectionOfInterfaces([Context::class, ParametersAwareInterface::class]); + $context->expects($this->once())->method('setParameters')->with(self::PARAMETERS); + + $this->createInitializer()->initializeContext($context); + } + + public function testDriverAwareContextReceivesEveryCollaborator(): void { + $driver_manager = $this->createMock(DriverManagerInterface::class); + $dispatcher = $this->createHookDispatcher(); + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $user_manager = $this->createMock(UserManagerInterface::class); + + $context = $this->createMock(DriverAwareInterface::class); + $context->expects($this->once())->method('setParameters')->with(self::PARAMETERS); + $context->expects($this->once())->method('setDriverManager')->with($driver_manager); + $context->expects($this->once())->method('setDispatcher')->with($dispatcher); + $context->expects($this->once())->method('setAuthenticationManager')->with($authentication_manager); + $context->expects($this->once())->method('setUserManager')->with($user_manager); + + $initializer = new DriverAwareInitializer($driver_manager, self::PARAMETERS, $dispatcher, $authentication_manager, $user_manager); + $initializer->initializeContext($context); + } + + /** + * Builds an initializer over stubbed collaborators. + */ + protected function createInitializer(): DriverAwareInitializer { + return new DriverAwareInitializer( + $this->createMock(DriverManagerInterface::class), + self::PARAMETERS, + $this->createHookDispatcher(), + $this->createMock(AuthenticationManagerInterface::class), + $this->createMock(UserManagerInterface::class), + ); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Context/RawContextTest.php b/tests/phpunit/src/Unit/Behat/Context/RawContextTest.php new file mode 100644 index 00000000..6db840f6 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Context/RawContextTest.php @@ -0,0 +1,657 @@ +envBackup = $existing === FALSE ? NULL : $existing; + putenv('BEHAT_STEPS_DISABLE_CLEANUP'); + } + + protected function tearDown(): void { + if ($this->envBackup === NULL) { + putenv('BEHAT_STEPS_DISABLE_CLEANUP'); + } + else { + putenv('BEHAT_STEPS_DISABLE_CLEANUP=' . $this->envBackup); + } + } + + public function testImplementsDriverAwareInterface(): void { + $this->assertInstanceOf(DriverAwareInterface::class, new RawContext()); + } + + /** + * Tests that an uninitialized context reports what it is missing. + * + * @param string $method + * The accessor to call on an uninitialized context. + * @param string $expected_message + * The message the accessor is expected to throw with. + */ + #[DataProvider('dataProviderUninitializedContextNamesMissingCollaborator')] + public function testUninitializedContextNamesMissingCollaborator(string $method, string $expected_message): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected_message); + + (new RawContext())->$method(); + } + + public static function dataProviderUninitializedContextNamesMissingCollaborator(): \Iterator { + yield 'driver manager' => ['getDriverManager', 'The driver manager is available only after Behat has initialized the context.']; + yield 'user manager' => ['getUserManager', 'The user manager is available only after Behat has initialized the context.']; + yield 'authentication manager' => ['getAuthenticationManager', 'The authentication manager is available only after Behat has initialized the context.']; + } + + public function testTheDriverComesFromTheManager(): void { + $driver = $this->createMock(DriverInterface::class); + $context = $this->createContext($driver); + + $this->assertSame($driver, $context->getDriver()); + } + + public function testTheRandomGeneratorComesFromTheDriver(): void { + $random = new Random(); + $driver = $this->createMock(DriverInterface::class); + $driver->method('getRandom')->willReturn($random); + + $this->assertSame($random, $this->createContext($driver)->getRandom()); + } + + public function testNodeCreationDelegatesAndTracksTheStub(): void { + $driver = $this->createContentDriver(); + $stub = new EntityStub('node', 'page', ['title' => 'A title']); + $driver->expects($this->once())->method('nodeCreate')->with($stub)->willReturn($stub); + + $context = $this->createContext($driver); + + $this->assertSame($stub, $context->nodeCreate($stub)); + $this->assertSame([$stub], $context->getCreatedStubs()); + } + + public function testTermCreationDelegatesAndTracksTheStub(): void { + $driver = $this->createContentDriver(); + $stub = new EntityStub('taxonomy_term', 'tags', ['name' => 'A term']); + $driver->expects($this->once())->method('termCreate')->with($stub)->willReturn($stub); + + $context = $this->createContext($driver); + + $this->assertSame($stub, $context->termCreate($stub)); + $this->assertSame([$stub], $context->getCreatedStubs()); + } + + public function testAnEmptyTermParentIsDropped(): void { + $driver = $this->createContentDriver(); + $stub = new EntityStub('taxonomy_term', 'tags', ['name' => 'A term', 'parent' => '']); + $driver->method('termCreate')->willReturn($stub); + + $this->createContext($driver)->termCreate($stub); + + $this->assertFalse($stub->hasValue('parent')); + } + + public function testNamedTermParentIsKept(): void { + $driver = $this->createContentDriver(); + $stub = new EntityStub('taxonomy_term', 'tags', ['name' => 'A term', 'parent' => 'Another term']); + $driver->method('termCreate')->willReturn($stub); + + $this->createContext($driver)->termCreate($stub); + + $this->assertSame('Another term', $stub->getValue('parent')); + } + + public function testGenericEntityCreationDelegatesAndTracksTheStub(): void { + $driver = $this->createContentDriver(); + $stub = new EntityStub('block_content', 'basic', ['info' => 'A block']); + $driver->expects($this->once())->method('entityCreate')->with($stub)->willReturn($stub); + + $context = $this->createContext($driver); + + $this->assertSame($stub, $context->entityCreate($stub)); + $this->assertSame([$stub], $context->getCreatedStubs()); + } + + public function testScalarValuesSurviveTheDriverCall(): void { + $stub = new EntityStub('node', 'page', ['title' => 'A title']); + + $driver = $this->createContentDriver(); + $driver->method('nodeCreate')->willReturnCallback(static function (EntityStub $stub): EntityStub { + // The driver expands base fields into the storage shape. + $stub->setValue('title', [['value' => 'A title']]); + + return $stub; + }); + + $this->createContext($driver)->nodeCreate($stub); + + $this->assertSame('A title', $stub->getValue('title')); + } + + public function testUserCreationRegistersTheUser(): void { + $driver = $this->createDriver([UserCapabilityInterface::class]); + $stub = new EntityStub('user', NULL, ['name' => 'alice']); + $driver->expects($this->once())->method('userCreate')->with($stub); + + $user_manager = new UserManager(); + $context = $this->createContext($driver, $user_manager); + + $this->assertSame($stub, $context->userCreate($stub)); + $this->assertSame($stub, $user_manager->getUser('alice')); + } + + public function testLanguageCreationTracksTheReturnedStub(): void { + $driver = $this->createDriver([LanguageCapabilityInterface::class]); + $stub = new EntityStub('language', NULL, ['langcode' => 'fr']); + $driver->method('languageCreate')->willReturn($stub); + + $context = $this->createContext($driver); + + $this->assertSame($stub, $context->languageCreate($stub)); + $this->assertSame([$stub], $context->getCreatedStubs()); + } + + public function testAnExistingLanguageIsNotTracked(): void { + $driver = $this->createDriver([LanguageCapabilityInterface::class]); + $driver->method('languageCreate')->willReturn(FALSE); + + $context = $this->createContext($driver); + + $this->assertFalse($context->languageCreate(new EntityStub('language', NULL, ['langcode' => 'fr']))); + $this->assertSame([], $context->getCreatedStubs()); + } + + /** + * Tests that creation is refused when the driver lacks the capability. + * + * @param string $method + * The creation method to call. + * @param \DrevOps\BehatSteps\Driver\Entity\EntityStub $stub + * The stub to pass to it. + * @param string $expected_message + * The message the guard is expected to throw with. + */ + #[DataProvider('dataProviderCreationRefusesIncapableDriver')] + public function testCreationRefusesIncapableDriver(string $method, EntityStub $stub, string $expected_message): void { + $context = $this->createContext($this->createMock(DriverInterface::class)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected_message); + + $context->$method($stub); + } + + public static function dataProviderCreationRefusesIncapableDriver(): \Iterator { + yield 'node' => ['nodeCreate', new EntityStub('node'), 'does not support content creation.']; + yield 'term' => ['termCreate', new EntityStub('taxonomy_term'), 'does not support content creation.']; + yield 'entity' => ['entityCreate', new EntityStub('block_content'), 'does not support content creation.']; + yield 'user' => ['userCreate', new EntityStub('user'), 'does not support user creation.']; + yield 'language' => ['languageCreate', new EntityStub('language'), 'does not support language management.']; + } + + public function testHookExceptionSurfacesFromDispatcher(): void { + $manager = new EnvironmentManager(); + $manager->registerEnvironmentReader(new ThrowingHookReader()); + + $call_center = new CallCenter(); + $call_center->registerCallHandler(new RuntimeCallHandler()); + + $dispatcher = new HookDispatcher(new HookRepository($manager), $call_center); + + $context = $this->createContext($this->createContentDriver(), NULL, NULL, $dispatcher); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The hook failed.'); + + $context->nodeCreate(new EntityStub('node', 'page', ['title' => 'A title'])); + } + + public function testHooksCannotBeDispatchedBeforeInitialization(): void { + $context = new TestableRawContext(); + $context->setDriverManager($this->createMock(DriverManagerInterface::class)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The hook dispatcher is available only after Behat has initialized the context.'); + + $context->entityCreate(new EntityStub('block_content')); + } + + public function testHooksCannotBeDispatchedBeforeScenarioStarts(): void { + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getEnvironment')->willReturn(NULL); + + $context = new TestableRawContext(); + $context->setDriverManager($driver_manager); + $context->setDispatcher($this->createHookDispatcher()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Hooks can be dispatched only once a scenario has started.'); + + $context->entityCreate(new EntityStub('block_content')); + } + + public function testCreatedEntitiesAreRemovedInReverseOrder(): void { + $node = new EntityStub('node', 'page', ['title' => 'A node']); + $term = new EntityStub('taxonomy_term', 'tags', ['name' => 'A term']); + $block = new EntityStub('block_content', 'basic'); + + $deleted = []; + $driver = $this->createContentDriver(); + $driver->method('nodeDelete')->willReturnCallback(static function (EntityStub $stub) use (&$deleted): void { + $deleted[] = 'node'; + }); + $driver->method('termDelete')->willReturnCallback(static function (EntityStub $stub) use (&$deleted): bool { + $deleted[] = 'term'; + + return TRUE; + }); + $driver->method('entityDelete')->willReturnCallback(static function (EntityStub $stub) use (&$deleted): void { + $deleted[] = 'entity'; + }); + + $context = $this->createContext($driver); + $context->setCreatedStubs([$term, $node, $block]); + + $context->cleanEntities(); + + $this->assertSame(['entity', 'node', 'term'], $deleted); + $this->assertSame([], $context->getCreatedStubs()); + } + + /** + * Tests that both language entity types route to the language capability. + * + * @param string $entity_type + * The entity type of the tracked stub. + */ + #[DataProvider('dataProviderLanguageIsRemovedThroughLanguageCapability')] + public function testLanguageIsRemovedThroughLanguageCapability(string $entity_type): void { + $driver = $this->createDriver([LanguageCapabilityInterface::class, ContentCapabilityInterface::class]); + $driver->expects($this->once())->method('languageDelete'); + $driver->expects($this->never())->method('entityDelete'); + + $context = $this->createContext($driver); + $context->setCreatedStubs([new EntityStub($entity_type, NULL, ['langcode' => 'fr'])]); + + $context->cleanEntities(); + } + + public static function dataProviderLanguageIsRemovedThroughLanguageCapability(): \Iterator { + yield 'language' => ['language']; + yield 'configurable_language' => ['configurable_language']; + } + + public function testLanguageIsLeftBehindByIncapableDriver(): void { + $driver = $this->createContentDriver(); + $driver->expects($this->never())->method('entityDelete'); + + $context = $this->createContext($driver); + $context->setCreatedStubs([new EntityStub('language', NULL, ['langcode' => 'fr'])]); + + $context->cleanEntities(); + + $this->assertSame([], $context->getCreatedStubs()); + } + + public function testEntitiesAreLeftBehindByIncapableDriver(): void { + $context = $this->createContext($this->createMock(DriverInterface::class)); + $context->setCreatedStubs([new EntityStub('node', 'page')]); + + $context->cleanEntities(); + + $this->assertSame([], $context->getCreatedStubs()); + } + + public function testNothingIsDeletedWhenNoEntityWasCreated(): void { + $driver = $this->createContentDriver(); + $driver->expects($this->never())->method('entityDelete'); + + $this->createContext($driver)->cleanEntities(); + } + + public function testCreatedUsersAreDeletedAndTheBatchIsDrained(): void { + $driver = $this->createDriver([UserCapabilityInterface::class, BatchCapabilityInterface::class]); + $driver->expects($this->once())->method('userDelete'); + $driver->expects($this->once())->method('processBatch'); + + $user_manager = new UserManager(); + $user_manager->addUser(new EntityStub('user', NULL, ['name' => 'alice'])); + + $this->createContext($driver, $user_manager)->cleanUsers(); + + $this->assertFalse($user_manager->hasUsers()); + } + + public function testUsersAreLeftBehindByIncapableDriver(): void { + $user_manager = new UserManager(); + $user_manager->addUser(new EntityStub('user', NULL, ['name' => 'alice'])); + + $this->createContext($this->createMock(DriverInterface::class), $user_manager)->cleanUsers(); + + $this->assertTrue($user_manager->hasUsers()); + } + + public function testSessionIsResetWhenManagerSupportsFastLogout(): void { + /** @var \DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface&\DrevOps\BehatSteps\Behat\Manager\FastLogoutInterface&\PHPUnit\Framework\MockObject\MockObject $authentication_manager */ + $authentication_manager = $this->createMockForIntersectionOfInterfaces([AuthenticationManagerInterface::class, FastLogoutInterface::class]); + $authentication_manager->expects($this->once())->method('fastLogout'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->cleanUsers(); + } + + public function testKnownUserIsLoggedOutWithoutFastLogout(): void { + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->once())->method('logOut'); + + $user_manager = new UserManager(); + $user_manager->setCurrentUser(new EntityStub('user', NULL, ['name' => 'alice'])); + + $this->createContext($this->createMock(DriverInterface::class), $user_manager, $authentication_manager)->cleanUsers(); + } + + public function testAnAnonymousSessionIsLeftAloneWhenTheManagerHasNoFastLogout(): void { + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->never())->method('logOut'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->cleanUsers(); + } + + public function testCreatedRolesAreDeleted(): void { + $driver = $this->createDriver([RoleCapabilityInterface::class]); + $driver->expects($this->exactly(2))->method('roleDelete'); + + $context = $this->createContext($driver); + $context->setRoles(['editor', 'reviewer']); + + $context->cleanRoles(); + + $this->assertSame([], $context->getRoles()); + } + + public function testRolesAreLeftBehindByIncapableDriver(): void { + $context = $this->createContext($this->createMock(DriverInterface::class)); + $context->setRoles(['editor']); + + $context->cleanRoles(); + + $this->assertSame(['editor'], $context->getRoles()); + } + + public function testNoRoleIsDeletedWhenNoneWasCreated(): void { + $driver = $this->createDriver([RoleCapabilityInterface::class]); + $driver->expects($this->never())->method('roleDelete'); + + $this->createContext($driver)->cleanRoles(); + } + + public function testStaticCachesAreClearedOnCacheCapableDriver(): void { + $driver = $this->createDriver([CacheCapabilityInterface::class]); + $driver->expects($this->once())->method('cacheClearStatic'); + + $this->createContext($driver)->clearStaticCaches(); + } + + public function testStaticCachesAreSkippedOnAnIncapableDriver(): void { + $this->expectNotToPerformAssertions(); + + $this->createContext($this->createMock(DriverInterface::class))->clearStaticCaches(); + } + + /** + * Tests which values of the opt-out variable disable cleanup. + * + * @param string $value + * The value of the cleanup opt-out variable. + * @param bool $expected_cleanup + * Whether cleanup is expected to run. + */ + #[DataProvider('dataProviderCleanupOptOut')] + public function testCleanupOptOut(string $value, bool $expected_cleanup): void { + putenv('BEHAT_STEPS_DISABLE_CLEANUP=' . $value); + + $driver = $this->createContentDriver(); + $driver->expects($expected_cleanup ? $this->once() : $this->never())->method('nodeDelete'); + + $context = $this->createContext($driver); + $context->setCreatedStubs([new EntityStub('node', 'page')]); + + $context->cleanEntities(); + } + + public static function dataProviderCleanupOptOut(): \Iterator { + yield 'empty value still cleans up' => ['', TRUE]; + yield 'unrecognised value still cleans up' => ['maybe', TRUE]; + yield 'zero still cleans up' => ['0', TRUE]; + yield 'one disables cleanup' => ['1', FALSE]; + yield 'true disables cleanup' => ['TRUE', FALSE]; + yield 'yes disables cleanup' => ['yes', FALSE]; + yield 'on disables cleanup' => [' On ', FALSE]; + } + + public function testTheOptOutAlsoSkipsUserCleanup(): void { + putenv('BEHAT_STEPS_DISABLE_CLEANUP=1'); + + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->never())->method('logOut'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->cleanUsers(); + } + + public function testTheOptOutAlsoSkipsRoleCleanup(): void { + putenv('BEHAT_STEPS_DISABLE_CLEANUP=1'); + + $context = $this->createContext($this->createMock(DriverInterface::class)); + $context->setRoles(['editor']); + + $context->cleanRoles(); + + $this->assertSame(['editor'], $context->getRoles()); + } + + public function testStringTimestampIsConvertedForInProcessDriver(): void { + $stub = new EntityStub('node', 'page', ['created' => '1 January 2025 UTC']); + $context = $this->createContext(new DrupalDriver(self::DRUPAL_ROOT, 'default')); + + RawContext::alterNodeParameters(new BeforeNodeCreateScope($this->createMock(Environment::class), $context, $stub)); + + $this->assertSame(strtotime('1 January 2025 UTC'), $stub->getValue('created')); + } + + /** + * Tests that a value the driver already accepts is not rewritten. + * + * @param mixed $value + * The value seeded on the timestamp field. + */ + #[DataProvider('dataProviderNonTextualTimestampIsLeftAlone')] + public function testNonTextualTimestampIsLeftAlone(mixed $value): void { + $stub = new EntityStub('node', 'page', ['created' => $value]); + $context = $this->createContext(new DrupalDriver(self::DRUPAL_ROOT, 'default')); + + RawContext::alterNodeParameters(new BeforeNodeCreateScope($this->createMock(Environment::class), $context, $stub)); + + $this->assertSame($value, $stub->getValue('created')); + } + + public static function dataProviderNonTextualTimestampIsLeftAlone(): \Iterator { + yield 'numeric timestamp' => ['1735689600']; + yield 'empty value' => ['']; + yield 'absent value' => [NULL]; + } + + public function testUnreadableTimestampIsReported(): void { + $stub = new EntityStub('node', 'page', ['created' => 'not a date at all']); + $context = $this->createContext(new DrupalDriver(self::DRUPAL_ROOT, 'default')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to read the "created" value "not a date at all" as a date.'); + + RawContext::alterNodeParameters(new BeforeNodeCreateScope($this->createMock(Environment::class), $context, $stub)); + } + + public function testTimestampConversionIsSkippedForForeignContext(): void { + $stub = new EntityStub('node', 'page', ['created' => '1 January 2025']); + $scope = new BeforeNodeCreateScope($this->createMock(Environment::class), $this->createMock(Context::class), $stub); + + RawContext::alterNodeParameters($scope); + + $this->assertSame('1 January 2025', $stub->getValue('created')); + } + + public function testTimestampConversionIsSkippedForRemoteDriver(): void { + $stub = new EntityStub('node', 'page', ['created' => '1 January 2025']); + $context = $this->createContext($this->createMock(DriverInterface::class)); + + RawContext::alterNodeParameters(new BeforeNodeCreateScope($this->createMock(Environment::class), $context, $stub)); + + $this->assertSame('1 January 2025', $stub->getValue('created')); + } + + public function testLoginDelegatesToTheAuthenticationManager(): void { + $user = new EntityStub('user', NULL, ['name' => 'alice']); + + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->once())->method('logIn')->with($user); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->login($user); + } + + public function testLogoutDelegatesToTheAuthenticationManager(): void { + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->once())->method('logOut'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->logout(); + } + + public function testFastLogoutIsUsedWhenAskedForAndSupported(): void { + /** @var \DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface&\DrevOps\BehatSteps\Behat\Manager\FastLogoutInterface&\PHPUnit\Framework\MockObject\MockObject $authentication_manager */ + $authentication_manager = $this->createMockForIntersectionOfInterfaces([AuthenticationManagerInterface::class, FastLogoutInterface::class]); + $authentication_manager->expects($this->once())->method('fastLogout'); + $authentication_manager->expects($this->never())->method('logOut'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->logout(TRUE); + } + + public function testFastLogoutFallsBackWhenUnsupported(): void { + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->expects($this->once())->method('logOut'); + + $this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->logout(TRUE); + } + + public function testLoggedInDelegatesToTheAuthenticationManager(): void { + $authentication_manager = $this->createMock(AuthenticationManagerInterface::class); + $authentication_manager->method('loggedIn')->willReturn(TRUE); + + $this->assertTrue($this->createContext($this->createMock(DriverInterface::class), NULL, $authentication_manager)->loggedIn()); + } + + /** + * Builds an initialized context over the given driver. + * + * @param \DrevOps\BehatSteps\Driver\DriverInterface $driver + * The driver the manager hands out. + * @param \DrevOps\BehatSteps\Behat\Manager\UserManagerInterface|null $user_manager + * The user manager, when the test inspects it. + * @param \DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface|null $authentication_manager + * The authentication manager, when the test inspects it. + * @param \Behat\Testwork\Hook\HookDispatcher|null $dispatcher + * The hook dispatcher, when the test needs one that finds hooks. + */ + protected function createContext(DriverInterface $driver, ?UserManagerInterface $user_manager = NULL, ?AuthenticationManagerInterface $authentication_manager = NULL, ?HookDispatcher $dispatcher = NULL): TestableRawContext { + $environment = $this->createMock(Environment::class); + // A real environment binds a callee to the context instance it holds; the + // fixture hooks are static, so handing back the callee's own callable is + // enough for the dispatcher to invoke them. + $environment->method('bindCallee')->willReturnCallback(static fn(Callee $callee): mixed => $callee->getCallable()); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($driver); + $driver_manager->method('getEnvironment')->willReturn($environment); + + $context = new TestableRawContext(); + $context->setDriverManager($driver_manager); + $context->setDispatcher($dispatcher ?? $this->createHookDispatcher()); + $context->setUserManager($user_manager ?? new UserManager()); + $context->setAuthenticationManager($authentication_manager ?? $this->createMock(AuthenticationManagerInterface::class)); + + return $context; + } + + /** + * Builds a driver double implementing the given capabilities. + * + * @param array $capabilities + * The capability interfaces the driver should satisfy. + * + * @return \DrevOps\BehatSteps\Driver\DriverInterface&\PHPUnit\Framework\MockObject\MockObject + * The driver double. + */ + protected function createDriver(array $capabilities): DriverInterface&MockObject { + /** @var \DrevOps\BehatSteps\Driver\DriverInterface&\PHPUnit\Framework\MockObject\MockObject $driver */ + $driver = $this->createMockForIntersectionOfInterfaces([DriverInterface::class, ...$capabilities]); + + return $driver; + } + + /** + * Builds a content-capable driver double. + * + * @return \DrevOps\BehatSteps\Driver\DriverInterface&\PHPUnit\Framework\MockObject\MockObject + * The driver double. + */ + protected function createContentDriver(): DriverInterface&MockObject { + return $this->createDriver([ContentCapabilityInterface::class]); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Fixtures/HookedContext.php b/tests/phpunit/src/Unit/Behat/Fixtures/HookedContext.php new file mode 100644 index 00000000..3f7bbb02 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Fixtures/HookedContext.php @@ -0,0 +1,69 @@ + + * The stubs, in creation order. + */ + public function getCreatedStubs(): array { + return $this->createdStubs; + } + + /** + * Seeds the creation registry. + * + * @param array $stubs + * The stubs to register as created. + */ + public function setCreatedStubs(array $stubs): void { + $this->createdStubs = $stubs; + } + + /** + * Seeds the role registry. + * + * @param array $roles + * The role names to register as created. + */ + public function setRoles(array $roles): void { + $this->roles = $roles; + } + + /** + * Returns the roles still registered for cleanup. + * + * @return array + * The role names. + */ + public function getRoles(): array { + return $this->roles; + } + + /** + * Public bridge to the protected vocabulary resolver. + */ + public function callResolveVocabularyMachineName(string $identifier): string { + return $this->resolveVocabularyMachineName($identifier); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Fixtures/ThrowingHookReader.php b/tests/phpunit/src/Unit/Behat/Fixtures/ThrowingHookReader.php new file mode 100644 index 00000000..2af44b2e --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Fixtures/ThrowingHookReader.php @@ -0,0 +1,40 @@ +createMock(Suite::class); + + $this->assertTrue($generator->supportsSuiteAndClass($suite, 'Anything')); + } + + /** + * Tests starter class output for namespaced and rootless context classes. + * + * @param string $context_class + * Fully qualified class name passed to the generator. + * @param string $expected + * Exact expected generated source. + */ + #[DataProvider('dataProviderGenerateClass')] + public function testGenerateClass(string $context_class, string $expected): void { + $generator = new ClassGenerator(); + $suite = $this->createMock(Suite::class); + + $this->assertSame($expected, $generator->generateClass($suite, $context_class)); + } + + /** + * Provides FQCN inputs and the exact source the generator should emit. + * + * @return \Iterator + * Cases keyed by description, each [context class FQCN, expected source]. + */ + public static function dataProviderGenerateClass(): \Iterator { + $namespaced = <<<'PHP' + ['App\\Tests\\Behat\\FeatureContext', $namespaced]; + yield 'class without namespace' => ['FeatureContext', $rootless]; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Hook/AttributeTest.php b/tests/phpunit/src/Unit/Behat/Hook/AttributeTest.php new file mode 100644 index 00000000..450a6b1c --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Hook/AttributeTest.php @@ -0,0 +1,104 @@ + $attribute_class + * The attribute to build. + */ + #[DataProvider('dataProviderFilterStringDefaultsToNone')] + public function testFilterStringDefaultsToNone(string $attribute_class): void { + $attribute = new $attribute_class(); + + $this->assertInstanceOf(DrupalHookInterface::class, $attribute); + $this->assertNull($attribute->getFilterString()); + } + + public static function dataProviderFilterStringDefaultsToNone(): \Iterator { + yield from self::attributeClasses(); + } + + /** + * Tests that a declared filter string is readable off the attribute. + * + * @param class-string<\DrevOps\BehatSteps\Behat\Hook\Attribute\DrupalHookInterface> $attribute_class + * The attribute to build. + */ + #[DataProvider('dataProviderFilterStringIsReadBack')] + public function testFilterStringIsReadBack(string $attribute_class): void { + $this->assertSame('@api', (new $attribute_class('@api'))->getFilterString()); + } + + public static function dataProviderFilterStringIsReadBack(): \Iterator { + yield from self::attributeClasses(); + } + + /** + * Tests that every attribute targets methods and repeats. + * + * @param class-string<\DrevOps\BehatSteps\Behat\Hook\Attribute\DrupalHookInterface> $attribute_class + * The attribute to reflect. + */ + #[DataProvider('dataProviderAttributeTargetsRepeatableMethods')] + public function testAttributeTargetsRepeatableMethods(string $attribute_class): void { + $attributes = (new \ReflectionClass($attribute_class))->getAttributes(\Attribute::class); + + $this->assertCount(1, $attributes); + $this->assertSame(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE, $attributes[0]->newInstance()->flags); + } + + public static function dataProviderAttributeTargetsRepeatableMethods(): \Iterator { + yield from self::attributeClasses(); + } + + /** + * Lists every hook attribute the reader maps to a call class. + * + * @return \Iterator + * One row per attribute, keyed by description. + */ + protected static function attributeClasses(): \Iterator { + yield 'before entity' => [BeforeEntityCreate::class]; + yield 'after entity' => [AfterEntityCreate::class]; + yield 'before node' => [BeforeNodeCreate::class]; + yield 'after node' => [AfterNodeCreate::class]; + yield 'before term' => [BeforeTermCreate::class]; + yield 'after term' => [AfterTermCreate::class]; + yield 'before user' => [BeforeUserCreate::class]; + yield 'after user' => [AfterUserCreate::class]; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Hook/CallTest.php b/tests/phpunit/src/Unit/Behat/Hook/CallTest.php new file mode 100644 index 00000000..112f4483 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Hook/CallTest.php @@ -0,0 +1,95 @@ + $call_class + * The hook call to build. + * @param string $expected_name + * The name the call is expected to report. + * @param string $expected_scope + * The scope name the call is expected to bind to. + */ + #[DataProvider('dataProviderNameAndScope')] + public function testNameAndScope(string $call_class, string $expected_name, string $expected_scope): void { + $call = new $call_class(NULL, HookedContext::beforeNode(...)); + + $this->assertSame($expected_name, $call->getName()); + $this->assertSame($expected_scope, $call->getScopeName()); + $this->assertNull($call->getFilterString()); + } + + public static function dataProviderNameAndScope(): \Iterator { + yield 'before entity' => [BeforeEntityCreate::class, 'BeforeEntityCreate', 'entity.create.before']; + yield 'after entity' => [AfterEntityCreate::class, 'AfterEntityCreate', 'entity.create.after']; + yield 'before node' => [BeforeNodeCreate::class, 'BeforeNodeCreate', 'node.create.before']; + yield 'after node' => [AfterNodeCreate::class, 'AfterNodeCreate', 'node.create.after']; + yield 'before term' => [BeforeTermCreate::class, 'BeforeTermCreate', 'term.create.before']; + yield 'after term' => [AfterTermCreate::class, 'AfterTermCreate', 'term.create.after']; + yield 'before user' => [BeforeUserCreate::class, 'BeforeUserCreate', 'user.create.before']; + yield 'after user' => [AfterUserCreate::class, 'AfterUserCreate', 'user.create.after']; + } + + public function testAnUnfilteredHookMatchesTheScope(): void { + $call = new BeforeNodeCreate(NULL, HookedContext::beforeNode(...)); + + $this->assertTrue($call->filterMatches($this->createScope())); + } + + public function testFilteredHookMatchesNothing(): void { + $call = new BeforeNodeCreate('@api', HookedContext::beforeNode(...)); + + $this->assertSame('@api', $call->getFilterString()); + $this->assertFalse($call->filterMatches($this->createScope())); + } + + public function testTheDescriptionIsCarried(): void { + $call = new BeforeNodeCreate(NULL, HookedContext::beforeNode(...), 'Alters node values.'); + + $this->assertSame('Alters node values.', $call->getDescription()); + } + + /** + * Builds a scope to filter a hook against. + */ + protected function createScope(): BeforeNodeCreateScope { + return new BeforeNodeCreateScope($this->createMock(Environment::class), $this->createMock(Context::class), new EntityStub('node')); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Hook/ScopeTest.php b/tests/phpunit/src/Unit/Behat/Hook/ScopeTest.php new file mode 100644 index 00000000..a4ee17fc --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Hook/ScopeTest.php @@ -0,0 +1,93 @@ + $scope_class + * The scope to build. + * @param string $expected + * The hook name the scope is expected to report. + */ + #[DataProvider('dataProviderScopeName')] + public function testScopeName(string $scope_class, string $expected): void { + $scope = new $scope_class($this->createMock(Environment::class), $this->createMock(Context::class), new EntityStub('node')); + + $this->assertSame($expected, $scope->getName()); + } + + public static function dataProviderScopeName(): \Iterator { + yield 'before entity' => [BeforeEntityCreateScope::class, EntityScopeInterface::BEFORE]; + yield 'after entity' => [AfterEntityCreateScope::class, EntityScopeInterface::AFTER]; + yield 'before node' => [BeforeNodeCreateScope::class, 'node.create.before']; + yield 'after node' => [AfterNodeCreateScope::class, 'node.create.after']; + yield 'before term' => [BeforeTermCreateScope::class, 'term.create.before']; + yield 'after term' => [AfterTermCreateScope::class, 'term.create.after']; + yield 'before user' => [BeforeUserCreateScope::class, 'user.create.before']; + yield 'after user' => [AfterUserCreateScope::class, 'user.create.after']; + yield 'before language' => [BeforeLanguageCreateScope::class, 'language.create.before']; + yield 'after language' => [AfterLanguageCreateScope::class, 'language.create.after']; + } + + public function testTheScopeCarriesItsContextStubAndEnvironment(): void { + $context = $this->createMock(Context::class); + $environment = $this->createMock(Environment::class); + $stub = new EntityStub('node', 'page', ['title' => 'Test']); + + $scope = new BeforeNodeCreateScope($environment, $context, $stub); + + $this->assertSame($context, $scope->getContext()); + $this->assertSame($stub, $scope->getStub()); + $this->assertSame($environment, $scope->getEnvironment()); + } + + public function testTheSuiteComesFromTheEnvironment(): void { + $suite = $this->createMock(Suite::class); + $environment = $this->createMock(Environment::class); + $environment->method('getSuite')->willReturn($suite); + + $scope = new BeforeNodeCreateScope($environment, $this->createMock(Context::class), new EntityStub('node')); + + $this->assertSame($suite, $scope->getSuite()); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Listener/DriverListenerTest.php b/tests/phpunit/src/Unit/Behat/Listener/DriverListenerTest.php new file mode 100644 index 00000000..c5d3254a --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Listener/DriverListenerTest.php @@ -0,0 +1,102 @@ + 'blackbox', + 'api_driver' => 'drupal', + 'javascript_driver' => 'drush', + ]; + + public function testItSubscribesToScenariosAndExamples(): void { + $events = DriverListener::getSubscribedEvents(); + + $this->assertSame(['prepareDefaultDriver', 11], $events[ScenarioTested::BEFORE]); + $this->assertSame(['prepareDefaultDriver', 11], $events[ExampleTested::BEFORE]); + } + + /** + * Tests which driver a set of feature and scenario tags selects. + * + * @param list $feature_tags + * Tags declared on the feature. + * @param list $scenario_tags + * Tags declared on the scenario. + * @param string $expected + * The driver name expected to be selected. + */ + #[DataProvider('dataProviderDriverSelection')] + public function testDriverSelection(array $feature_tags, array $scenario_tags, string $expected): void { + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->expects($this->once())->method('setDefaultDriverName')->with($expected); + + $listener = new DriverListener($driver_manager, self::PARAMETERS); + $listener->prepareDefaultDriver($this->createEvent($feature_tags, $scenario_tags)); + } + + public static function dataProviderDriverSelection(): \Iterator { + yield 'no tags falls back to the default driver' => [[], [], 'blackbox']; + yield 'a feature tag selects its driver' => [['api'], [], 'drupal']; + yield 'a scenario tag selects its driver' => [[], ['api'], 'drupal']; + yield 'a tag without a configured driver is ignored' => [[], ['wip'], 'blackbox']; + yield 'the last matching tag wins' => [['api'], ['javascript'], 'drush']; + } + + public function testMissingDriverConfigurationIsReported(): void { + $listener = new DriverListener($this->createMock(DriverManagerInterface::class), []); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No driver is configured for this scenario: set "default_driver" in the extension configuration.'); + + $listener->prepareDefaultDriver($this->createEvent([], [])); + } + + public function testTheEnvironmentIsHandedToTheManager(): void { + $event = $this->createEvent([], []); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->expects($this->once())->method('setEnvironment')->with($event->getEnvironment()); + + $listener = new DriverListener($driver_manager, self::PARAMETERS); + $listener->prepareDefaultDriver($event); + } + + /** + * Builds the event Behat dispatches before a scenario or an example. + * + * @param list $feature_tags + * Tags declared on the feature. + * @param list $scenario_tags + * Tags declared on the scenario. + */ + protected function createEvent(array $feature_tags, array $scenario_tags): BeforeScenarioTested { + $scenario = new ScenarioNode('Scenario', $scenario_tags, [], 'Scenario', 2); + $feature = new FeatureNode('Feature', NULL, $feature_tags, NULL, [$scenario], 'Feature', 'en', NULL, 1); + + return new BeforeScenarioTested($this->createMock(Environment::class), $feature, $scenario); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Manager/AuthenticationManagerTest.php b/tests/phpunit/src/Unit/Behat/Manager/AuthenticationManagerTest.php new file mode 100644 index 00000000..ddfe684c --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Manager/AuthenticationManagerTest.php @@ -0,0 +1,803 @@ + [ + 'log_in' => 'Log in', + 'log_out' => 'Log out', + 'login_url' => '/user/login', + 'logout_url' => '/user/logout', + 'logout_confirm_url' => '/user/logout/confirm', + 'username_field' => 'Username', + 'password_field' => 'Password', + ], + 'selectors' => [ + 'logged_in_selector' => 'body.logged-in', + 'login_form_selector' => 'form#user-login', + ], + ]; + + protected const MINK_PARAMS = [ + 'base_url' => 'http://localhost', + ]; + + public function testImplementsInterfaces(): void { + $manager = $this->createManager(); + $this->assertInstanceOf(AuthenticationManagerInterface::class, $manager); + $this->assertInstanceOf(FastLogoutInterface::class, $manager); + } + + public function testLogInSuccess(): void { + $submit = $this->createMock(NodeElement::class); + $submit->expects($this->once())->method('click'); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $user_manager = new UserManager(); + $driver_manager = $this->createDriverManagerMock(); + $manager = $this->createManager($session, $user_manager, $driver_manager); + + $user = new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password']); + $manager->logIn($user); + $this->assertSame($user, $user_manager->getCurrentUser()); + } + + #[DataProvider('dataProviderLogInFieldValue')] + public function testLogInFieldValue(?string $login_field, string $expected_value): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + $filled = []; + $page->expects($this->exactly(2))->method('fillField')->willReturnCallback(function (string $field, string $value) use (&$filled): void { + $filled[$field] = $value; + }); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $params = self::EXTENSION_PARAMS; + if ($login_field !== NULL) { + $params['login_field'] = $login_field; + } + + $manager = $this->createManager($session, NULL, NULL, $params); + $user = new EntityStub('user', NULL, ['name' => 'admin', 'mail' => 'admin@example.com', 'pass' => 'password']); + $manager->logIn($user); + + $this->assertSame($expected_value, $filled['Username'] ?? NULL); + } + + public static function dataProviderLogInFieldValue(): \Iterator { + yield 'defaults to name when not configured' => [NULL, 'admin']; + yield 'explicit name uses name' => ['name', 'admin']; + yield 'mail uses mail' => ['mail', 'admin@example.com']; + } + + public function testLogInThrowsWhenNoSubmitButton(): void { + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->willReturn(NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/login'); + + $manager = $this->createManager($session); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Submit button matching css "login form" not found.'); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'pass'])); + } + + #[DataProvider('dataProviderLogInThrowsWhenNotLoggedIn')] + public function testLogInThrowsWhenNotLoggedIn(EntityStubInterface $user, string $expected_message): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->willReturn($submit); + $page->method('has')->willReturn(FALSE); + $page->method('findLink')->willReturn(NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $manager = $this->createManager($session); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage($expected_message); + $manager->logIn($user); + } + + public static function dataProviderLogInThrowsWhenNotLoggedIn(): \Iterator { + yield 'user without role' => [ + new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'pass']), + "Unable to determine if logged in because 'Log out' ('log_out') link cannot be found for user 'admin'", + ]; + yield 'user with role' => [ + new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'pass', 'role' => 'administrator']), + "Unable to determine if logged in because 'Log out' ('log_out') link cannot be found for user 'admin' with role 'administrator'", + ]; + } + + public function testLogInCallsBackendDriver(): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $auth_driver = $this->createAuthDriverMock(); + $auth_driver->expects($this->once())->method('login'); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($auth_driver); + + $manager = $this->createManager($session, NULL, $driver_manager); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'pass'])); + } + + public function testLogout(): void { + $page = $this->createMock(DocumentElement::class); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->expects($this->once())->method('visit'); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/logout'); + + $user_manager = new UserManager(); + $user_manager->setCurrentUser(new EntityStub('user', NULL, ['name' => 'admin'])); + + $driver_manager = $this->createDriverManagerMock(); + $manager = $this->createManager($session, $user_manager, $driver_manager); + $manager->logOut(); + $this->assertFalse($user_manager->getCurrentUser()); + } + + public function testLogoutWithConfirmationPage(): void { + $submit = $this->createMock(NodeElement::class); + $submit->expects($this->once())->method('click'); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log out')->willReturn($submit); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/logout/confirm'); + + $user_manager = new UserManager(); + $driver_manager = $this->createDriverManagerMock(); + $manager = $this->createManager($session, $user_manager, $driver_manager); + $manager->logOut(); + $this->assertFalse($user_manager->getCurrentUser()); + } + + public function testLogoutWithConfirmationPageThrowsWhenNoButton(): void { + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->willReturn(NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/logout/confirm'); + + $manager = $this->createManager($session); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Logout button matching css "logout confirmation page" not found.'); + $manager->logOut(); + } + + public function testLogoutCallsBackendDriver(): void { + $page = $this->createMock(DocumentElement::class); + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/logout'); + + $auth_driver = $this->createAuthDriverMock(); + $auth_driver->expects($this->once())->method('logout'); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($auth_driver); + + $manager = $this->createManager($session, NULL, $driver_manager); + $manager->logOut(); + } + + #[DataProvider('dataProviderLoggedIn')] + public function testLoggedIn(bool $session_started, bool $has_logged_in_selector, bool $has_login_form, bool $has_logout_link, bool $expected): void { + $page = $this->createMock(DocumentElement::class); + + $has_map = []; + if ($session_started) { + $has_map[] = ['css', 'body.logged-in', $has_logged_in_selector]; + if (!$has_logged_in_selector) { + $has_map[] = ['css', 'form#user-login', $has_login_form]; + } + } + $page->method('has')->willReturnMap($has_map); + $page->method('findLink')->willReturn($has_logout_link ? $this->createMock(NodeElement::class) : NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn($session_started); + + $manager = $this->createManager($session); + $this->assertSame($expected, $manager->loggedIn()); + } + + public static function dataProviderLoggedIn(): \Iterator { + yield 'session not started' => [FALSE, FALSE, FALSE, FALSE, FALSE]; + yield 'logged in selector found' => [TRUE, TRUE, FALSE, FALSE, TRUE]; + yield 'login form found means not logged in' => [TRUE, FALSE, TRUE, FALSE, FALSE]; + yield 'logout link found means logged in' => [TRUE, FALSE, FALSE, TRUE, TRUE]; + yield 'nothing found means not logged in' => [TRUE, FALSE, FALSE, FALSE, FALSE]; + } + + public function testLoggedInReturnsFalseWhenPageNotAvailable(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(TRUE); + $session->method('getPage')->willReturn(NULL); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $manager = new AuthenticationManager($mink, new UserManager(), $this->createDriverManagerMock(), self::MINK_PARAMS, self::EXTENSION_PARAMS); + $this->assertFalse($manager->loggedIn()); + } + + /** + * Tests that loggedIn() polls for the logout link when login_wait > 0. + * + * Simulates the Critical CSS / late JS race: the logged-in selector + * never appears, the login form is absent (we are logged in), and the + * logout link is initially missing but materialises a few polls later. + * With login_wait > 0, the third-resort check must keep polling. + */ + public function testLoggedInPollsForLogoutLinkWhenLoginWaitSet(): void { + $link = $this->createMock(NodeElement::class); + + $call_count = 0; + $page = $this->createMock(DocumentElement::class); + $page->method('has')->willReturn(FALSE); + $page->method('findLink')->willReturnCallback(function () use (&$call_count, $link): ?NodeElement { + $call_count++; + return $call_count >= 3 ? $link : NULL; + }); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 2; + + $manager = $this->createManager($session, NULL, NULL, $params); + $this->assertTrue($manager->loggedIn()); + $this->assertGreaterThanOrEqual(3, $call_count); + } + + /** + * Tests that loggedIn() does not poll when login_wait is 0. + * + * Confirms the wait loop is skipped entirely when waiting is disabled, + * preserving the historical single-lookup behaviour for the third- + * resort check. + */ + public function testLoggedInDoesNotPollWhenLoginWaitIsZero(): void { + $call_count = 0; + $page = $this->createMock(DocumentElement::class); + $page->method('has')->willReturn(FALSE); + $page->method('findLink')->willReturnCallback(function () use (&$call_count): ?NodeElement { + $call_count++; + return NULL; + }); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 0; + + $manager = $this->createManager($session, NULL, NULL, $params); + $this->assertFalse($manager->loggedIn()); + $this->assertSame(1, $call_count); + } + + /** + * Tests that loggedIn() returns FALSE when the wait elapses. + * + * The logout link never appears, so the wait expires after login_wait + * seconds and the method falls through to the anonymous-state cleanup. + */ + public function testLoggedInReturnsFalseWhenLogoutLinkWaitTimesOut(): void { + $page = $this->createMock(DocumentElement::class); + $page->method('has')->willReturn(FALSE); + $page->method('findLink')->willReturn(NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 1; + + $manager = $this->createManager($session, NULL, NULL, $params); + $start = microtime(TRUE); + $this->assertFalse($manager->loggedIn()); + $elapsed = microtime(TRUE) - $start; + $this->assertGreaterThanOrEqual(1.0, $elapsed); + } + + public function testLoggedInHandlesDriverException(): void { + $page = $this->createMock(DocumentElement::class); + $page->method('has')->willReturnCallback(function ($selector, $locator): true { + if ($locator === 'body.logged-in') { + throw new DriverException('Not loaded'); + } + return TRUE; + }); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + + $manager = $this->createManager($session); + // Should not throw — login form is found so returns false. + $this->assertFalse($manager->loggedIn()); + } + + public function testFastLogoutResetsSession(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(TRUE); + $session->expects($this->once())->method('reset'); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $user_manager = new UserManager(); + $user_manager->setCurrentUser(new EntityStub('user', NULL, ['name' => 'admin'])); + + $driver_manager = $this->createDriverManagerMock(); + $manager = new AuthenticationManager($mink, $user_manager, $driver_manager, self::MINK_PARAMS, self::EXTENSION_PARAMS); + $manager->fastLogout(); + + $this->assertFalse($user_manager->getCurrentUser()); + } + + public function testFastLogoutSkipsResetWhenNotStarted(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(FALSE); + $session->expects($this->never())->method('reset'); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $driver_manager = $this->createDriverManagerMock(); + $manager = new AuthenticationManager($mink, new UserManager(), $driver_manager, self::MINK_PARAMS, self::EXTENSION_PARAMS); + $manager->fastLogout(); + } + + public function testFastLogoutCallsBackendDriver(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(FALSE); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $auth_driver = $this->createAuthDriverMock(); + $auth_driver->expects($this->once())->method('logout'); + + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($auth_driver); + + $manager = new AuthenticationManager($mink, new UserManager(), $driver_manager, self::MINK_PARAMS, self::EXTENSION_PARAMS); + $manager->fastLogout(); + } + + /** + * Tests that applyBasicAuth() applies credentials parsed from base_url. + * + * @param string $base_url + * The configured Mink 'base_url'. + * @param array{0: string, 1: string}|null $expected + * The [username, password] expected to be applied, or NULL when basic + * auth should not be applied at all. + */ + #[DataProvider('dataProviderApplyBasicAuth')] + public function testApplyBasicAuth(string $base_url, ?array $expected): void { + $session = $this->createMock(Session::class); + + if ($expected === NULL) { + $session->expects($this->never())->method('setBasicAuth'); + } + else { + $session->expects($this->once())->method('setBasicAuth')->with($expected[0], $expected[1]); + } + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $manager = new AuthenticationManager($mink, new UserManager(), $this->createDriverManagerMock(), ['base_url' => $base_url], self::EXTENSION_PARAMS); + $manager->applyBasicAuth(); + } + + public static function dataProviderApplyBasicAuth(): \Iterator { + yield 'base_url userinfo is used' => [ + 'http://bob:s3cret@localhost', + ['bob', 's3cret'], + ]; + yield 'base_url user without password uses empty password' => [ + 'http://bob@localhost', + ['bob', ''], + ]; + yield 'url-encoded userinfo is decoded' => [ + 'http://bob%40corp:p%40ss@localhost', + ['bob@corp', 'p@ss'], + ]; + yield 'literal plus in userinfo is preserved' => [ + 'http://bob+corp:p+ss@localhost', + ['bob+corp', 'p+ss'], + ]; + yield 'no credentials is a no-op' => [ + 'http://localhost', + NULL, + ]; + } + + public function testFastLogoutReappliesBasicAuth(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(TRUE); + $session->expects($this->once())->method('reset'); + $session->expects($this->once())->method('setBasicAuth')->with('alice', 'secret'); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $manager = new AuthenticationManager($mink, new UserManager(), $this->createDriverManagerMock(), ['base_url' => 'http://alice:secret@localhost'], self::EXTENSION_PARAMS); + $manager->fastLogout(); + } + + /** + * Tests that fastLogout() skips basic auth when the session is not started. + * + * Nothing was reset, so there are no cleared headers to restore. + */ + public function testFastLogoutSkipsBasicAuthWhenSessionNotStarted(): void { + $session = $this->createMock(Session::class); + $session->method('isStarted')->willReturn(FALSE); + $session->expects($this->never())->method('setBasicAuth'); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $manager = new AuthenticationManager($mink, new UserManager(), $this->createDriverManagerMock(), ['base_url' => 'http://alice:secret@localhost'], self::EXTENSION_PARAMS); + $manager->fastLogout(); + } + + /** + * Tests that applyBasicAuth() swallows an unsupported-driver exception. + * + * JavaScript drivers cannot set basic auth headers and throw; the call must + * be a no-op for them rather than aborting the scenario. + */ + public function testApplyBasicAuthIgnoresUnsupportedDriver(): void { + $session = $this->createMock(Session::class); + $session->expects($this->once())->method('setBasicAuth')->willThrowException(new UnsupportedDriverActionException('Basic auth setup is not supported by %s', $this->createMock(MinkDriverInterface::class))); + + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + $manager = new AuthenticationManager($mink, new UserManager(), $this->createDriverManagerMock(), ['base_url' => 'http://alice:secret@localhost'], self::EXTENSION_PARAMS); + $manager->applyBasicAuth(); + } + + public function testGetLogoutElement(): void { + $link = $this->createMock(NodeElement::class); + $page = $this->createMock(DocumentElement::class); + $page->method('findLink')->with('Log out')->willReturn($link); + + $session = $this->createSessionMock($page); + $manager = $this->createManager($session); + $this->assertSame($link, $manager->getLogoutElement()); + } + + protected function createSessionMock(?DocumentElement $page = NULL): Session { + $session = $this->createMock(Session::class); + $session->method('getPage')->willReturn($page ?? $this->createMock(DocumentElement::class)); + $session->method('getDriver')->willReturn($this->createMock(MinkDriverInterface::class)); + return $session; + } + + /** + * Creates a mock for the AuthenticationCapability and DriverInterface. + * + * @return \DrevOps\BehatSteps\Driver\Capability\AuthenticationCapabilityInterface&\DrevOps\BehatSteps\Driver\DriverInterface&\PHPUnit\Framework\MockObject\MockObject + * The mocked driver. + */ + protected function createAuthDriverMock(): AuthenticationCapabilityInterface&DriverInterface&MockObject { + /** @var \DrevOps\BehatSteps\Driver\Capability\AuthenticationCapabilityInterface&\DrevOps\BehatSteps\Driver\DriverInterface&\PHPUnit\Framework\MockObject\MockObject $driver */ + $driver = $this->createMockForIntersectionOfInterfaces([ + AuthenticationCapabilityInterface::class, + DriverInterface::class, + ]); + $driver->method('isBootstrapped')->willReturn(TRUE); + return $driver; + } + + protected function createDriverManagerMock(): DriverManagerInterface { + $driver = $this->createMock(DriverInterface::class); + $driver->method('isBootstrapped')->willReturn(TRUE); + $driver_manager = $this->createMock(DriverManagerInterface::class); + $driver_manager->method('getDriver')->willReturn($driver); + return $driver_manager; + } + + public function testLogInSkipsWaitWhenLoginWaitIsZero(): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // getCurrentUrl should never be called for wait purposes when disabled. + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/login'); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 0; + + $manager = $this->createManager($session, NULL, NULL, $params); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password'])); + } + + public function testLogInWaitsForLoggedInSelector(): void { + $submit = $this->createMock(NodeElement::class); + + $call_count = 0; + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + // Simulate: logged_in_selector not found on first call, found on second. + $page->method('has')->willReturnCallback(function (string $selector, string $locator) use (&$call_count): bool { + if ($locator === 'body.logged-in') { + $call_count++; + // First two calls return FALSE (during wait loop and loggedIn check), + // then return TRUE. + return $call_count > 2; + } + return FALSE; + }); + $page->method('find')->willReturnCallback(function (string $selector, string $locator) use ($page): ?DocumentElement { + if ($locator === 'body') { + return $page; + } + return NULL; + }); + + $url_call_count = 0; + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // Simulate URL change after login (redirect). + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturnCallback(function () use (&$url_call_count): string { + $url_call_count++; + return $url_call_count <= 1 ? 'http://localhost/user/login' : 'http://localhost/user/1'; + }); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 1; + + $user_manager = new UserManager(); + $manager = $this->createManager($session, $user_manager, NULL, $params); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password'])); + + $this->assertNotFalse($user_manager->getCurrentUser()); + } + + /** + * Tests that logIn() polls until the page body renders. + * + * A driver can return a page whose body has not been written yet, so the + * wait loop keeps looking rather than moving on to the logged-in check. + */ + public function testLogInWaitsForTheBodyToRender(): void { + $submit = $this->createMock(NodeElement::class); + + $find_count = 0; + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + $page->method('find')->willReturnCallback(function (string $selector, string $locator) use (&$find_count, $page): ?DocumentElement { + if ($locator !== 'body') { + return NULL; + } + + $find_count++; + + return $find_count >= 3 ? $page : NULL; + }); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/1'); + + $params = self::EXTENSION_PARAMS; + $params['login_wait'] = 2; + + $manager = $this->createManager($session, NULL, NULL, $params); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password'])); + + $this->assertGreaterThanOrEqual(3, $find_count); + } + + /** + * Tests that logIn() without login_wait throws when selector is delayed. + * + * Demonstrates the race condition: without login_wait, a delayed + * logged_in_selector causes login to fail even though login succeeded. + */ + public function testLogInFailsWithoutLoginWaitWhenSelectorDelayed(): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->willReturnCallback(fn(string $text): ?NodeElement => $text === 'Log in' ? $submit : NULL); + // logged_in_selector is never found (simulates slow JS). + $page->method('has')->willReturn(FALSE); + $page->method('findLink')->willReturn(NULL); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/user/1'); + + // No login_wait configured — the race condition scenario. + $manager = $this->createManager($session); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Unable to determine if logged in"); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password'])); + } + + public function testLogInVisitsConfiguredLoginUrl(): void { + $submit = $this->createMock(NodeElement::class); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log in')->willReturn($submit); + $page->method('has')->willReturn(TRUE); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->method('isStarted')->willReturn(TRUE); + // @phpstan-ignore method.notFound + $session->expects($this->once())->method('visit')->with('http://localhost/custom-login'); + + $params = self::EXTENSION_PARAMS; + $params['text']['login_url'] = '/custom-login'; + + $manager = $this->createManager($session, NULL, NULL, $params); + $manager->logIn(new EntityStub('user', NULL, ['name' => 'admin', 'pass' => 'password'])); + } + + public function testLogoutVisitsConfiguredLogoutUrl(): void { + $page = $this->createMock(DocumentElement::class); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->expects($this->once())->method('visit')->with('http://localhost/custom-logout'); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/custom-logout'); + + $params = self::EXTENSION_PARAMS; + $params['text']['logout_url'] = '/custom-logout'; + + $user_manager = new UserManager(); + $user_manager->setCurrentUser(new EntityStub('user', NULL, ['name' => 'admin'])); + + $manager = $this->createManager($session, $user_manager, NULL, $params); + $manager->logOut(); + $this->assertFalse($user_manager->getCurrentUser()); + } + + public function testLogoutConfirmUsesConfiguredUrls(): void { + $submit = $this->createMock(NodeElement::class); + $submit->expects($this->once())->method('click'); + + $page = $this->createMock(DocumentElement::class); + $page->method('findButton')->with('Log out')->willReturn($submit); + + $session = $this->createSessionMock($page); + // @phpstan-ignore method.notFound + $session->expects($this->once())->method('visit')->with('http://localhost/custom-logout'); + // @phpstan-ignore method.notFound + $session->method('getCurrentUrl')->willReturn('http://localhost/custom-logout/confirm'); + + $params = self::EXTENSION_PARAMS; + $params['text']['logout_url'] = '/custom-logout'; + $params['text']['logout_confirm_url'] = '/custom-logout/confirm'; + + $user_manager = new UserManager(); + $manager = $this->createManager($session, $user_manager, NULL, $params); + $manager->logOut(); + $this->assertFalse($user_manager->getCurrentUser()); + } + + /** + * Creates a AuthenticationManager with optional overrides. + * + * @param \Behat\Mink\Session|null $session + * Optional Mink session override. + * @param \DrevOps\BehatSteps\Behat\Manager\UserManagerInterface|null $user_manager + * Optional user manager override. + * @param \DrevOps\BehatSteps\Behat\Manager\DriverManagerInterface|null $driver_manager + * Optional driver manager override. + * @param array|null $parameters + * Optional Drupal parameters override. + */ + protected function createManager(?Session $session = NULL, ?UserManagerInterface $user_manager = NULL, ?DriverManagerInterface $driver_manager = NULL, ?array $parameters = NULL): AuthenticationManager { + $session ??= $this->createSessionMock(); + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + return new AuthenticationManager( + $mink, + $user_manager ?? new UserManager(), + $driver_manager ?? $this->createDriverManagerMock(), + self::MINK_PARAMS, + $parameters ?? self::EXTENSION_PARAMS + ); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Manager/DriverManagerTest.php b/tests/phpunit/src/Unit/Behat/Manager/DriverManagerTest.php new file mode 100644 index 00000000..98a705b2 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Manager/DriverManagerTest.php @@ -0,0 +1,161 @@ +assertInstanceOf(DriverManagerInterface::class, $manager); + } + + public function testConstructorRegistersDrivers(): void { + $driver = $this->createDriverMock(TRUE); + + $manager = new DriverManager(['Alpha' => $driver]); + + $this->assertSame($driver, $manager->getDriver('alpha')); + $this->assertCount(1, $manager->getDrivers()); + } + + public function testConstructorLowercasesDriverNames(): void { + $driver = $this->createDriverMock(TRUE); + + $manager = new DriverManager(['MY_DRIVER' => $driver]); + + $this->assertSame($driver, $manager->getDriver('my_driver')); + } + + public function testRegisterDriverLowercasesName(): void { + $driver = $this->createDriverMock(TRUE); + $manager = new DriverManager(); + + $manager->registerDriver('FooBar', $driver); + + $this->assertSame($driver, $manager->getDriver('foobar')); + } + + public function testGetDriverReturnsDefaultDriver(): void { + $driver = $this->createDriverMock(TRUE); + $manager = new DriverManager(); + $manager->registerDriver('default', $driver); + $manager->setDefaultDriverName('default'); + + $this->assertSame($driver, $manager->getDriver()); + } + + public function testGetDriverThrowsWithoutDefault(): void { + $manager = new DriverManager(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Specify a Drupal driver to get.'); + + $manager->getDriver(); + } + + public function testGetDriverThrowsForUnregisteredName(): void { + $manager = new DriverManager(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Driver "ghost" is not registered'); + + $manager->getDriver('ghost'); + } + + public function testGetDriverBootstrapsWhenNeeded(): void { + $driver = $this->createMock(DriverInterface::class); + $driver->method('isBootstrapped')->willReturn(FALSE); + $driver->expects($this->once())->method('bootstrap'); + $manager = new DriverManager(['test' => $driver]); + + $manager->getDriver('test'); + } + + public function testGetDriverSkipsBootstrapWhenAlreadyBootstrapped(): void { + $driver = $this->createMock(DriverInterface::class); + $driver->method('isBootstrapped')->willReturn(TRUE); + $driver->expects($this->never())->method('bootstrap'); + $manager = new DriverManager(['test' => $driver]); + + $manager->getDriver('test'); + } + + public function testGetDriversReturnsEmptyByDefault(): void { + $manager = new DriverManager(); + + $this->assertSame([], $manager->getDrivers()); + } + + public function testGetDriversReturnsAllRegistered(): void { + $driver_a = $this->createDriverMock(TRUE); + $driver_b = $this->createDriverMock(TRUE); + $manager = new DriverManager(); + $manager->registerDriver('a', $driver_a); + $manager->registerDriver('b', $driver_b); + + $drivers = $manager->getDrivers(); + + $this->assertCount(2, $drivers); + $this->assertSame($driver_a, $drivers['a']); + $this->assertSame($driver_b, $drivers['b']); + } + + public function testSetDefaultDriverNameThrowsForUnregistered(): void { + $manager = new DriverManager(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Driver "missing" is not registered.'); + + $manager->setDefaultDriverName('missing'); + } + + public function testSetDefaultDriverNameLowercases(): void { + $driver = $this->createDriverMock(TRUE); + $manager = new DriverManager(); + $manager->registerDriver('mydriver', $driver); + + $manager->setDefaultDriverName('MyDriver'); + + $this->assertSame($driver, $manager->getDriver()); + } + + public function testGetEnvironmentReturnsNullByDefault(): void { + $manager = new DriverManager(); + + $this->assertNull($manager->getEnvironment()); + } + + public function testSetAndGetEnvironment(): void { + $environment = $this->createMock(Environment::class); + $manager = new DriverManager(); + + $manager->setEnvironment($environment); + + $this->assertSame($environment, $manager->getEnvironment()); + } + + /** + * Creates a driver double reporting the given bootstrap state. + */ + protected function createDriverMock(bool $bootstrapped): DriverInterface { + $driver = $this->createMock(DriverInterface::class); + $driver->method('isBootstrapped')->willReturn($bootstrapped); + + return $driver; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Manager/MailManagerTest.php b/tests/phpunit/src/Unit/Behat/Manager/MailManagerTest.php new file mode 100644 index 00000000..ed60b22f --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Manager/MailManagerTest.php @@ -0,0 +1,60 @@ + $extra_driver_methods + * Additional driver methods that should be called. + */ + #[DataProvider('dataProviderDriverDelegation')] + public function testDriverDelegation(string $method, string $driver_method, array $extra_driver_methods = []): void { + $driver = $this->createMock(MailCapabilityInterface::class); + $driver->expects($this->once())->method($driver_method); + + foreach ($extra_driver_methods as $extra_driver_method) { + $driver->expects($this->once())->method($extra_driver_method); + } + + $manager = new MailManager($driver); + $manager->$method(); + } + + public static function dataProviderDriverDelegation(): \Iterator { + yield 'startCollectingMail calls driver and clears' => ['startCollectingMail', 'mailStartCollecting', ['mailClear']]; + yield 'stopCollectingMail delegates to driver' => ['stopCollectingMail', 'mailStopCollecting']; + yield 'disableMail starts collecting' => ['disableMail', 'mailStartCollecting', ['mailClear']]; + yield 'enableMail stops collecting' => ['enableMail', 'mailStopCollecting']; + yield 'clearMail delegates to driver' => ['clearMail', 'mailClear']; + } + + public function testGetMailDelegatesToDriver(): void { + $expected = [['to' => 'a@b.com', 'subject' => 'test', 'body' => 'hello']]; + $driver = $this->createMock(MailCapabilityInterface::class); + $driver->expects($this->once())->method('mailGet')->willReturn($expected); + + $manager = new MailManager($driver); + + $this->assertSame($expected, $manager->getMail()); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Manager/UserManagerTest.php b/tests/phpunit/src/Unit/Behat/Manager/UserManagerTest.php new file mode 100644 index 00000000..2a583446 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Manager/UserManagerTest.php @@ -0,0 +1,178 @@ +assertInstanceOf(UserManagerInterface::class, $manager); + } + + public function testCurrentUserDefaultsToFalse(): void { + $manager = new UserManager(); + + $this->assertFalse($manager->getCurrentUser()); + } + + public function testSetAndGetCurrentUser(): void { + $manager = new UserManager(); + $user = self::userStub(['name' => 'admin']); + + $manager->setCurrentUser($user); + + $this->assertSame($user, $manager->getCurrentUser()); + } + + public function testSetCurrentUserToFalse(): void { + $manager = new UserManager(); + $manager->setCurrentUser(self::userStub(['name' => 'admin'])); + + $manager->setCurrentUser(FALSE); + + $this->assertFalse($manager->getCurrentUser()); + } + + public function testAddAndGetUser(): void { + $manager = new UserManager(); + $user = self::userStub(['name' => 'editor']); + + $manager->addUser($user); + + $this->assertSame($user, $manager->getUser('editor')); + } + + public function testGetUserThrowsForUnknown(): void { + $manager = new UserManager(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No user with ghost name is registered with the driver.'); + + $manager->getUser('ghost'); + } + + public function testRemoveUser(): void { + $manager = new UserManager(); + $manager->addUser(self::userStub(['name' => 'editor'])); + + $manager->removeUser('editor'); + + $this->expectException(\InvalidArgumentException::class); + $manager->getUser('editor'); + } + + public function testGetUsersReturnsAll(): void { + $manager = new UserManager(); + $user_a = self::userStub(['name' => 'alice']); + $user_b = self::userStub(['name' => 'bob']); + $manager->addUser($user_a); + $manager->addUser($user_b); + + $users = $manager->getUsers(); + + $this->assertCount(2, $users); + $this->assertSame($user_a, $users['alice']); + $this->assertSame($user_b, $users['bob']); + } + + public function testGetUsersReturnsEmptyByDefault(): void { + $manager = new UserManager(); + + $this->assertSame([], $manager->getUsers()); + } + + public function testClearUsers(): void { + $manager = new UserManager(); + $manager->setCurrentUser(self::userStub(['name' => 'admin'])); + $manager->addUser(self::userStub(['name' => 'editor'])); + + $manager->clearUsers(); + + $this->assertFalse($manager->getCurrentUser()); + $this->assertSame([], $manager->getUsers()); + } + + /** + * Tests whether the manager reports holding any users. + * + * @param array $users + * Users to add to the manager. + * @param bool $expected + * Expected hasUsers() result. + */ + #[DataProvider('dataProviderHasUsers')] + public function testHasUsers(array $users, bool $expected): void { + $manager = new UserManager(); + foreach ($users as $user) { + $manager->addUser($user); + } + + $this->assertSame($expected, $manager->hasUsers()); + } + + public static function dataProviderHasUsers(): \Iterator { + yield 'no users' => [[], FALSE]; + yield 'one user' => [[self::userStub(['name' => 'alice'])], TRUE]; + yield 'multiple users' => [[self::userStub(['name' => 'alice']), self::userStub(['name' => 'bob'])], TRUE]; + } + + #[DataProvider('dataProviderCurrentUserIsAnonymous')] + public function testCurrentUserIsAnonymous(EntityStubInterface|false $user, bool $expected): void { + $manager = new UserManager(); + $manager->setCurrentUser($user); + + $this->assertSame($expected, $manager->currentUserIsAnonymous()); + } + + public static function dataProviderCurrentUserIsAnonymous(): \Iterator { + yield 'false is anonymous' => [FALSE, TRUE]; + yield 'user stub is not anonymous' => [self::userStub(['name' => 'admin']), FALSE]; + } + + #[DataProvider('dataProviderCurrentUserHasRole')] + public function testCurrentUserHasRole(EntityStubInterface|false $user, string $role, bool $expected): void { + $manager = new UserManager(); + $manager->setCurrentUser($user); + + $this->assertSame($expected, $manager->currentUserHasRole($role)); + } + + public static function dataProviderCurrentUserHasRole(): \Iterator { + yield 'anonymous has no role' => [FALSE, 'admin', FALSE]; + yield 'user without role property' => [self::userStub(['name' => 'alice']), 'editor', FALSE]; + yield 'user with matching role' => [self::userStub(['name' => 'alice', 'role' => 'editor']), 'editor', TRUE]; + yield 'user with non-matching role' => [self::userStub(['name' => 'alice', 'role' => 'editor']), 'admin', FALSE]; + yield 'user with empty role' => [self::userStub(['name' => 'alice', 'role' => '']), 'editor', FALSE]; + yield 'query is empty' => [self::userStub(['name' => 'alice', 'role' => 'editor']), '', FALSE]; + yield 'one of several held roles' => [self::userStub(['name' => 'alice', 'role' => 'editor, reviewer']), 'reviewer', TRUE]; + yield 'every queried role is held' => [self::userStub(['name' => 'alice', 'role' => 'editor, reviewer']), 'reviewer,editor', TRUE]; + yield 'one queried role is missing' => [self::userStub(['name' => 'alice', 'role' => 'editor, reviewer']), 'editor, admin', FALSE]; + yield 'whitespace around a role is ignored' => [self::userStub(['name' => 'alice', 'role' => ' editor ']), ' editor ', TRUE]; + } + + /** + * Builds a user stub with the given values. + * + * @param array $values + * The values to seed the stub with. + */ + protected static function userStub(array $values): EntityStubInterface { + return new EntityStub('user', NULL, $values); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/MinkAwareTraitTest.php b/tests/phpunit/src/Unit/Behat/MinkAwareTraitTest.php new file mode 100644 index 00000000..fd907e24 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/MinkAwareTraitTest.php @@ -0,0 +1,118 @@ +createMink($this->createMock(Session::class)); + $host = new MinkAwareObject(); + $host->setMink($mink); + + $this->assertSame($mink, $host->getMink()); + } + + public function testTheDefaultSessionIsReturned(): void { + $session = $this->createMock(Session::class); + $host = new MinkAwareObject(); + $host->setMink($this->createMink($session)); + + $this->assertSame($session, $host->getSession()); + } + + public function testAssertSessionReturnsTheSessionsAssertions(): void { + $host = new MinkAwareObject(); + $host->setMink($this->createMink($this->createMock(Session::class))); + + $this->assertInstanceOf(WebAssert::class, $host->assertSession()); + } + + public function testParametersDefaultToAnEmptyMap(): void { + $host = new MinkAwareObject(); + + $this->assertSame([], $host->getMinkParameters()); + $this->assertNull($host->getMinkParameter('base_url')); + } + + public function testParametersAreReadBackAsSet(): void { + $host = new MinkAwareObject(); + $host->setMinkParameters(['base_url' => 'http://localhost']); + + $this->assertSame(['base_url' => 'http://localhost'], $host->getMinkParameters()); + $this->assertSame('http://localhost', $host->getMinkParameter('base_url')); + } + + public function testSingleParameterCanBeOverridden(): void { + $host = new MinkAwareObject(); + $host->setMinkParameters(['base_url' => 'http://localhost']); + + $host->setMinkParameter('base_url', 'http://example.com'); + + $this->assertSame('http://example.com', $host->getMinkParameter('base_url')); + } + + /** + * Tests how a path is turned into a URL under the configured base. + * + * @param string $base_url + * The configured 'base_url'. + * @param string $path + * The path to locate. + * @param string $expected + * The URL the path is expected to resolve to. + */ + #[DataProvider('dataProviderLocatePath')] + public function testLocatePath(string $base_url, string $path, string $expected): void { + $host = new MinkAwareObject(); + $host->setMinkParameters(['base_url' => $base_url]); + + $this->assertSame($expected, $host->locatePath($path)); + } + + public static function dataProviderLocatePath(): \Iterator { + yield 'relative path is appended' => ['http://localhost', '/user', 'http://localhost/user']; + yield 'trailing and leading slashes collapse' => ['http://localhost/', '/user', 'http://localhost/user']; + yield 'path without a leading slash' => ['http://localhost', 'user', 'http://localhost/user']; + yield 'absolute URL is left alone' => ['http://localhost', 'http://example.com/user', 'http://example.com/user']; + yield 'https URL is left alone' => ['http://localhost', 'HTTPS://example.com/user', 'HTTPS://example.com/user']; + yield 'path starting with the scheme letters is relative' => ['http://localhost', '/http-status', 'http://localhost/http-status']; + yield 'bare path starting with the scheme letters is relative' => ['http://localhost', 'httpbin', 'http://localhost/httpbin']; + } + + public function testVisitPathVisitsTheLocatedUrl(): void { + $session = $this->createMock(Session::class); + $session->expects($this->once())->method('visit')->with('http://localhost/user'); + + $host = new MinkAwareObject(); + $host->setMink($this->createMink($session)); + $host->setMinkParameters(['base_url' => 'http://localhost']); + + $host->visitPath('/user'); + } + + /** + * Builds a Mink instance holding one default session. + */ + protected function createMink(Session $session): Mink { + $mink = new Mink(['default' => $session]); + $mink->setDefaultSessionName('default'); + + return $mink; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/ParametersTraitTest.php b/tests/phpunit/src/Unit/Behat/ParametersTraitTest.php new file mode 100644 index 00000000..a2badf4d --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/ParametersTraitTest.php @@ -0,0 +1,87 @@ + 'mail', + 'text' => ['log_out' => 'Sign out'], + 'selectors' => ['logged_in_selector' => 'body.logged-in'], + 'mappings' => ['User Login' => '/user/login'], + ]; + + public function testAnUnsetParameterIsNull(): void { + $this->assertNull($this->createHost()->getParameter('missing')); + } + + public function testSetParameterIsReturned(): void { + $this->assertSame('mail', $this->createHost()->getParameter('login_field')); + } + + public function testParametersDefaultToAnEmptyMap(): void { + $this->assertNull((new ParametersAwareObject())->getParameter('login_field')); + } + + public function testConfiguredTextIsReturned(): void { + $this->assertSame('Sign out', $this->createHost()->getDrupalText('log_out')); + } + + public function testConfiguredSelectorIsReturned(): void { + $this->assertSame('body.logged-in', $this->createHost()->getDrupalSelector('logged_in_selector')); + } + + public function testConfiguredMappingIsReturned(): void { + $this->assertSame('/user/login', $this->createHost()->getMapping('User Login')); + } + + /** + * Tests that a name absent from the configuration is rejected. + * + * @param string $method + * The accessor to call. + * @param string $name + * The name to look up. + * @param string $expected_message + * The message the accessor is expected to throw with. + */ + #[DataProvider('dataProviderUnknownNameThrows')] + public function testUnknownNameThrows(string $method, string $name, string $expected_message): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected_message); + + $this->createHost()->$method($name); + } + + public static function dataProviderUnknownNameThrows(): \Iterator { + yield 'text' => ['getDrupalText', 'log_in', 'No such Drupal string: log_in']; + yield 'selector' => ['getDrupalSelector', 'login_form_selector', 'No such selector configured: login_form_selector']; + yield 'mapping' => ['getMapping', 'User Registration', 'No such mapping: User Registration']; + } + + /** + * Builds a host seeded with the fixture parameters. + */ + protected function createHost(): ParametersAwareObject { + $host = new ParametersAwareObject(); + $host->setParameters(self::PARAMETERS); + + return $host; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/Selector/RegionSelectorTest.php b/tests/phpunit/src/Unit/Behat/Selector/RegionSelectorTest.php new file mode 100644 index 00000000..3b925330 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/Selector/RegionSelectorTest.php @@ -0,0 +1,61 @@ + '#header', 'Content' => '#main .region-content']; + + public function testImplementsMinkSelectorInterface(): void { + $this->assertInstanceOf(SelectorInterface::class, $this->createSelector()); + } + + public function testConfiguredRegionResolvesThroughCssSelector(): void { + $css = new CssSelector(); + + $this->assertSame($css->translateToXPath('#header'), $this->createSelector()->translateToXPath('Header')); + } + + /** + * Tests that a locator matching no region is rejected. + * + * @param string|array $locator + * The locator handed to the selector. + */ + #[DataProvider('dataProviderUnknownRegionThrows')] + public function testUnknownRegionThrows(string|array $locator): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("region isn't configured!"); + + $this->createSelector()->translateToXPath($locator); + } + + public static function dataProviderUnknownRegionThrows(): \Iterator { + yield 'name that matches no region' => ['Footer']; + yield 'array locator' => [['Header']]; + } + + /** + * Builds a selector over the fixture region map. + */ + protected function createSelector(): RegionSelector { + return new RegionSelector(new CssSelector(), self::REGIONS); + } + +} diff --git a/tests/phpunit/src/Unit/Behat/ServiceContainer/BehatStepsExtensionTest.php b/tests/phpunit/src/Unit/Behat/ServiceContainer/BehatStepsExtensionTest.php new file mode 100644 index 00000000..1a766d30 --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/ServiceContainer/BehatStepsExtensionTest.php @@ -0,0 +1,338 @@ +isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir(self::$fixtureDir); + } + + protected function setUp(): void { + $this->originalCwd = (string) getcwd(); + } + + protected function tearDown(): void { + chdir($this->originalCwd); + } + + public function testConfigKeyNamesTheExtension(): void { + $this->assertSame('behat_steps', (new BehatStepsExtension())->getConfigKey()); + } + + public function testInitializeTouchesNoOtherExtension(): void { + $manager = new ExtensionManager([]); + + (new BehatStepsExtension())->initialize($manager); + + $this->assertSame([], $manager->getExtensions()); + } + + public function testBlackboxDriverIsAlwaysRegistered(): void { + $container = $this->load([]); + + $this->assertTrue($container->hasDefinition('behat_steps.driver.blackbox')); + $this->assertFalse($container->hasDefinition('behat_steps.driver.drupal')); + $this->assertFalse($container->hasDefinition('behat_steps.driver.drush')); + } + + public function testServicesFileIsLoaded(): void { + $container = $this->load([]); + + $this->assertTrue($container->hasDefinition('behat_steps.driver_manager')); + $this->assertTrue($container->hasDefinition('behat_steps.authentication_manager')); + $this->assertTrue($container->hasDefinition('behat_steps.user_manager')); + $this->assertTrue($container->hasDefinition('behat_steps.context.initializer')); + $this->assertTrue($container->hasDefinition('behat_steps.context.attribute_reader')); + $this->assertTrue($container->hasDefinition('behat_steps.listener.driver')); + $this->assertTrue($container->hasDefinition('behat_steps.region_selector')); + $this->assertSame('blackbox', $container->getParameter('behat_steps.default_driver')); + } + + public function testDrupalDriverIsRegisteredWithItsRoot(): void { + $container = $this->load(['drupal' => ['drupal_root' => 'web']]); + + $this->assertTrue($container->hasDefinition('behat_steps.driver.drupal')); + $this->assertTrue($container->hasDefinition('behat_steps.driver.core')); + $this->assertSame('web', $container->getParameter('behat_steps.driver.drupal.drupal_root')); + } + + public function testDrushDriverIsRegisteredWithItsRoot(): void { + $container = $this->load(['drush' => ['root' => 'web']]); + + $this->assertTrue($container->hasDefinition('behat_steps.driver.drush')); + $this->assertSame('web', $container->getParameter('behat_steps.driver.drush.root')); + $this->assertFalse($container->getParameter('behat_steps.driver.drush.alias')); + } + + public function testDrushDriverAcceptsAliasInsteadOfRoot(): void { + $container = $this->load(['drush' => ['alias' => '@self']]); + + $this->assertSame('@self', $container->getParameter('behat_steps.driver.drush.alias')); + $this->assertFalse($container->getParameter('behat_steps.driver.drush.root')); + } + + public function testDrupalDriverRequiresItsRoot(): void { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The child config "drupal_root" under "behat_steps.drupal" must be configured'); + + $this->load(['drupal' => []]); + } + + public function testDrushDriverRequiresAliasOrRoot(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Drush `alias` or `root` path is required for the Drush driver.'); + + $this->load(['drush' => []]); + } + + public function testDrushGlobalOptionsReachTheDriver(): void { + $container = $this->load(['drush' => ['root' => 'web', 'global_options' => '--yes']]); + + $this->assertSame([['setArguments', ['--yes']]], $container->getDefinition('behat_steps.driver.drush')->getMethodCalls()); + } + + public function testDrushGlobalOptionsAreOptional(): void { + $container = $this->load(['drush' => ['root' => 'web']]); + + $this->assertSame([], $container->getDefinition('behat_steps.driver.drush')->getMethodCalls()); + } + + /** + * Tests that the configured region map reaches the container. + * + * @param array $config + * The extension configuration, before schema normalisation. + * @param array $expected + * The region map expected on the container parameter. + */ + #[DataProvider('dataProviderRegionsReachTheContainer')] + public function testRegionsReachTheContainer(array $config, array $expected): void { + $container = $this->load($config); + + $this->assertSame($expected, $container->getParameter('behat_steps.regions')); + + // The same map is surfaced through 'behat_steps.parameters', so a context + // using ParametersTrait resolves the value the 'region' selector uses. + $parameters = $container->getParameter('behat_steps.parameters'); + $this->assertIsArray($parameters); + $this->assertSame($expected, $parameters['regions']); + } + + public static function dataProviderRegionsReachTheContainer(): \Iterator { + yield 'configured map is exposed' => [ + ['regions' => ['Header' => '#header', 'Content' => '#main']], + ['Header' => '#header', 'Content' => '#main'], + ]; + + yield 'no regions yields an empty map' => [ + [], + [], + ]; + } + + /** + * Tests that grouped mappings flatten into one lookup map. + * + * @param array $config + * The extension configuration, before schema normalisation. + * @param array $expected + * The expected flattened mapping map. + */ + #[DataProvider('dataProviderMappingsFlatten')] + public function testMappingsFlatten(array $config, array $expected): void { + $parameters = $this->load($config)->getParameter('behat_steps.parameters'); + + $this->assertIsArray($parameters); + $this->assertSame($expected, $parameters['mappings']); + } + + public static function dataProviderMappingsFlatten(): \Iterator { + yield 'single group flattens to its entries' => [ + ['mappings' => ['paths' => ['User Registration' => '/user/register', 'User Login' => '/user/login']]], + ['User Registration' => '/user/register', 'User Login' => '/user/login'], + ]; + + yield 'multiple groups merge into one map' => [ + ['mappings' => ['paths' => ['Home' => '/'], 'text' => ['Greeting' => 'Hello']]], + ['Home' => '/', 'Greeting' => 'Hello'], + ]; + + yield 'no mappings yields an empty map' => [ + [], + [], + ]; + } + + public function testDuplicateMappingKeyAcrossGroupsThrows(): void { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Duplicate mapping key "Home" found in groups "paths" and "aliases" under "behat_steps: mappings:".'); + + $this->load(['mappings' => ['paths' => ['Home' => '/'], 'aliases' => ['Home' => '/front']]]); + } + + /** + * Tests the values the schema falls back to. + * + * @param string $name + * The configuration key to read. + * @param mixed $expected + * The value the schema is expected to default to. + */ + #[DataProvider('dataProviderSchemaDefaults')] + public function testSchemaDefaults(string $name, mixed $expected): void { + $parameters = $this->load([])->getParameter('behat_steps.parameters'); + + $this->assertIsArray($parameters); + $this->assertSame($expected, $parameters[$name]); + } + + public static function dataProviderSchemaDefaults(): \Iterator { + yield 'default_driver' => ['default_driver', 'blackbox']; + yield 'api_driver' => ['api_driver', 'drush']; + yield 'drush_driver' => ['drush_driver', 'drush']; + yield 'login_field' => ['login_field', 'name']; + yield 'login_wait' => ['login_wait', 0]; + yield 'ajax_timeout' => ['ajax_timeout', 5]; + yield 'text' => [ + 'text', + [ + 'login_url' => '/user', + 'logout_url' => '/user/logout', + 'logout_confirm_url' => '/user/logout/confirm', + 'log_in' => 'Log in', + 'log_out' => 'Log out', + 'password_field' => 'Password', + 'username_field' => 'Username', + ], + ]; + yield 'selectors' => [ + 'selectors', + [ + 'login_form_selector' => 'form#user-login,form#user-login-form', + 'logged_in_selector' => 'body.logged-in,body.user-logged-in', + ], + ]; + } + + public function testMessageSelectorsAreConfigurable(): void { + $parameters = $this->load(['selectors' => ['messages' => ['error' => '.messages--error']]])->getParameter('behat_steps.parameters'); + + $this->assertIsArray($parameters); + $this->assertSame(['error' => '.messages--error'], $parameters['selectors']['messages']); + } + + public function testProcessSwapsInTheContextClassGenerator(): void { + $extension = new BehatStepsExtension(); + $container = $this->load([], $extension); + + $extension->process($container); + + $this->assertSame(ClassGenerator::class, $container->getDefinition(ContextExtension::CLASS_GENERATOR_TAG . '.simple')->getClass()); + } + + public function testProcessRegistersTheTaggedDrivers(): void { + $extension = new BehatStepsExtension(); + $container = $this->load(['drupal' => ['drupal_root' => 'web']], $extension); + + $extension->process($container); + + $calls = $container->getDefinition('behat_steps.driver_manager')->getMethodCalls(); + $names = array_map(static fn(array $call): string => $call[0], $calls); + + $this->assertContains('registerDriver', $names); + $this->assertSame('setDefaultDriverName', end($names)); + } + + public function testAbsoluteBinaryPathIsReturnedAsIs(): void { + $this->assertSame('/usr/local/bin/drush', BehatStepsExtension::resolveBinaryPath('/usr/local/bin/drush')); + } + + public function testBareBinaryCommandIsReturnedAsIs(): void { + $this->assertSame('drush', BehatStepsExtension::resolveBinaryPath('drush')); + } + + public function testBinaryPathResolvesFromWorkingDirectory(): void { + chdir(self::$fixtureDir . '/project'); + + $this->assertSame(self::$fixtureDir . '/project/vendor/bin/drush', BehatStepsExtension::resolveBinaryPath('vendor/bin/drush')); + } + + public function testBinaryPathResolvesFromParentDirectory(): void { + chdir(self::$fixtureDir . '/project/web'); + + $this->assertSame(self::$fixtureDir . '/project/vendor/bin/drush', BehatStepsExtension::resolveBinaryPath('vendor/bin/drush')); + } + + public function testUnresolvableBinaryPathIsReturnedAsIs(): void { + chdir(self::$fixtureDir); + + $this->assertSame('some/nonexistent/binary', BehatStepsExtension::resolveBinaryPath('some/nonexistent/binary')); + } + + /** + * Runs a raw configuration array through the schema and into a container. + * + * @param array $config + * The extension configuration, before schema normalisation. + * @param \DrevOps\BehatSteps\Behat\ServiceContainer\BehatStepsExtension|null $extension + * The extension to load with, when the test needs it afterwards. + */ + protected function load(array $config, ?BehatStepsExtension $extension = NULL): ContainerBuilder { + $extension ??= new BehatStepsExtension(); + + $builder = new ArrayNodeDefinition(BehatStepsExtension::CONFIG_KEY); + $extension->configure($builder); + $tree = $builder->getNode(TRUE); + + $container = new ContainerBuilder(); + $extension->load($container, $tree->finalize($tree->normalize($config))); + + return $container; + } + +} diff --git a/tests/phpunit/src/Unit/Behat/ServiceContainer/DriverPassTest.php b/tests/phpunit/src/Unit/Behat/ServiceContainer/DriverPassTest.php new file mode 100644 index 00000000..23f93d0c --- /dev/null +++ b/tests/phpunit/src/Unit/Behat/ServiceContainer/DriverPassTest.php @@ -0,0 +1,108 @@ +setDefinition('behat_steps.driver.blackbox', (new Definition(BlackboxDriver::class))->addTag('behat_steps.driver', ['alias' => 'blackbox'])); + + (new DriverPass())->process($container); + + $this->assertFalse($container->hasDefinition('behat_steps.driver_manager')); + } + + public function testTaggedDriversAreRegisteredUnderTheirAlias(): void { + $container = $this->createContainer(); + $container->setDefinition('behat_steps.driver.blackbox', (new Definition(BlackboxDriver::class))->addTag('behat_steps.driver', ['alias' => 'blackbox'])); + + (new DriverPass())->process($container); + + $calls = $container->getDefinition('behat_steps.driver_manager')->getMethodCalls(); + + $this->assertSame('registerDriver', $calls[0][0]); + $this->assertSame('blackbox', $calls[0][1][0]); + $this->assertEquals(new Reference('behat_steps.driver.blackbox'), $calls[0][1][1]); + } + + public function testTaggedDriverWithoutAliasIsSkipped(): void { + $container = $this->createContainer(); + $container->setDefinition('behat_steps.driver.nameless', (new Definition(BlackboxDriver::class))->addTag('behat_steps.driver')); + + (new DriverPass())->process($container); + + $calls = $container->getDefinition('behat_steps.driver_manager')->getMethodCalls(); + + $this->assertCount(1, $calls); + $this->assertSame('setDefaultDriverName', $calls[0][0]); + } + + public function testTheDefaultDriverNameIsTakenFromTheParameter(): void { + $container = $this->createContainer('drush'); + + (new DriverPass())->process($container); + + $calls = $container->getDefinition('behat_steps.driver_manager')->getMethodCalls(); + + $this->assertSame(['setDefaultDriverName', ['drush']], $calls[0]); + } + + public function testTheDrupalDriverReceivesTheTaggedCore(): void { + $container = $this->createContainer(); + $container->setDefinition('behat_steps.driver.drupal', (new Definition(DrupalDriver::class))->addTag('behat_steps.driver', ['alias' => 'drupal'])); + $container->setDefinition('behat_steps.driver.core', (new Definition(Core::class))->addTag('behat_steps.core')); + + (new DriverPass())->process($container); + + $this->assertEquals([['setCore', [new Reference('behat_steps.driver.core')]]], $container->getDefinition('behat_steps.driver.drupal')->getMethodCalls()); + } + + public function testTheDrupalDriverIsLeftAloneWhenNoCoreIsTagged(): void { + $container = $this->createContainer(); + $container->setDefinition('behat_steps.driver.drupal', (new Definition(DrupalDriver::class))->addTag('behat_steps.driver', ['alias' => 'drupal'])); + + (new DriverPass())->process($container); + + $this->assertSame([], $container->getDefinition('behat_steps.driver.drupal')->getMethodCalls()); + } + + public function testOnlyDrupalDriverReceivesCore(): void { + $container = $this->createContainer(); + $container->setDefinition('behat_steps.driver.blackbox', (new Definition(BlackboxDriver::class))->addTag('behat_steps.driver', ['alias' => 'blackbox'])); + $container->setDefinition('behat_steps.driver.core', (new Definition(Core::class))->addTag('behat_steps.core')); + + (new DriverPass())->process($container); + + $this->assertSame([], $container->getDefinition('behat_steps.driver.blackbox')->getMethodCalls()); + } + + /** + * Builds a container holding the driver manager the pass looks for. + */ + protected function createContainer(string $default_driver = 'blackbox'): ContainerBuilder { + $container = new ContainerBuilder(); + $container->setDefinition('behat_steps.driver_manager', new Definition(DriverManager::class)); + $container->setParameter('behat_steps.default_driver', $default_driver); + + return $container; + } + +} diff --git a/tests/phpunit/src/UnitTestCase.php b/tests/phpunit/src/UnitTestCase.php index 129650f5..1868cdc3 100644 --- a/tests/phpunit/src/UnitTestCase.php +++ b/tests/phpunit/src/UnitTestCase.php @@ -9,7 +9,11 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\FeatureNode; use Behat\Gherkin\Node\ScenarioInterface; +use Behat\Testwork\Call\CallCenter; use Behat\Testwork\Environment\Environment; +use Behat\Testwork\Environment\EnvironmentManager; +use Behat\Testwork\Hook\HookDispatcher; +use Behat\Testwork\Hook\HookRepository; use Behat\Testwork\Hook\Scope\AfterSuiteScope; use Behat\Testwork\Hook\Scope\BeforeSuiteScope; use Behat\Testwork\Specification\SpecificationIterator; @@ -37,6 +41,16 @@ protected static function isVocabularyPath(string $relative_path): bool { return str_starts_with($relative_path, 'Steps' . DIRECTORY_SEPARATOR); } + /** + * Build a hook dispatcher that finds no hooks. + * + * The dispatcher and everything it composes are final, so a test that needs + * one builds the real chain over an empty environment manager. + */ + protected function createHookDispatcher(): HookDispatcher { + return new HookDispatcher(new HookRepository(new EnvironmentManager()), new CallCenter()); + } + /** * Build a scope for a BeforeSuite hook. */