Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions src/Behat/Context/Attribute/HookAttributeReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace DrevOps\BehatSteps\Behat\Context\Attribute;

use Behat\Behat\Context\Attribute\AttributeReader;
use DrevOps\BehatSteps\Behat\Hook\Attribute\AfterEntityCreate as AfterEntityCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\AfterNodeCreate as AfterNodeCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\AfterTermCreate as AfterTermCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\AfterUserCreate as AfterUserCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\BeforeEntityCreate as BeforeEntityCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\BeforeNodeCreate as BeforeNodeCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\BeforeTermCreate as BeforeTermCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\BeforeUserCreate as BeforeUserCreateAttribute;
use DrevOps\BehatSteps\Behat\Hook\Attribute\DrupalHookInterface;
use DrevOps\BehatSteps\Behat\Hook\Call\AfterEntityCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\AfterNodeCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\AfterTermCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\AfterUserCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\BeforeEntityCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\BeforeNodeCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\BeforeTermCreate;
use DrevOps\BehatSteps\Behat\Hook\Call\BeforeUserCreate;

/**
* Reads the entity creation hook attributes off a context method.
*/
class HookAttributeReader implements AttributeReader {

/**
* Behat's factory for a callable that survives late instance binding.
*
* Behat 4 types the callee constructor as 'callable', and a
* '[class-string, method]' pair is not callable for an instance method, so
* it wraps such methods instead. The class is absent on Behat 3, which
* accepts the pair directly.
*/
protected const CALLABLE_FACTORY = 'Behat\\Behat\\Context\\ContextMethodCallableFactory';

/**
* Map of attribute classes to their hook call classes.
*
* @var array<class-string, class-string<\DrevOps\BehatSteps\Behat\Hook\Call\EntityHook>>
*/
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
}

}
59 changes: 59 additions & 0 deletions src/Behat/Context/DriverAwareInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace DrevOps\BehatSteps\Behat\Context;

use Behat\Behat\Context\Context;
use Behat\Testwork\Hook\HookDispatcher;
use DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface;
use DrevOps\BehatSteps\Behat\Manager\DriverManagerInterface;
use DrevOps\BehatSteps\Behat\Manager\UserManagerInterface;
use DrevOps\BehatSteps\Behat\ParametersAwareInterface;

/**
* Contract for contexts wired to the driver manager and its collaborators.
*
* @see \DrevOps\BehatSteps\Behat\Context\Initializer\DriverAwareInitializer
*/
interface DriverAwareInterface extends Context, ParametersAwareInterface {

/**
* Sets the driver manager.
*/
public function setDriverManager(DriverManagerInterface $driverManager): void;

/**
* Returns the driver manager.
*
* @throws \RuntimeException
* When the context has not been initialized by Behat yet.
*/
public function getDriverManager(): DriverManagerInterface;

/**
* Sets the hook dispatcher.
*/
public function setDispatcher(HookDispatcher $dispatcher): void;

/**
* Sets the user manager.
*/
public function setUserManager(UserManagerInterface $userManager): void;

/**
* Returns the user manager.
*/
public function getUserManager(): UserManagerInterface;

/**
* Sets the authentication manager.
*/
public function setAuthenticationManager(AuthenticationManagerInterface $authenticationManager): void;

/**
* Returns the authentication manager.
*/
public function getAuthenticationManager(): AuthenticationManagerInterface;

}
66 changes: 66 additions & 0 deletions src/Behat/Context/Initializer/DriverAwareInitializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace DrevOps\BehatSteps\Behat\Context\Initializer;

use Behat\Behat\Context\Context;
use Behat\Behat\Context\Initializer\ContextInitializer;
use Behat\Testwork\Hook\HookDispatcher;
use DrevOps\BehatSteps\Behat\Context\DriverAwareInterface;
use DrevOps\BehatSteps\Behat\Manager\AuthenticationManagerInterface;
use DrevOps\BehatSteps\Behat\Manager\DriverManagerInterface;
use DrevOps\BehatSteps\Behat\Manager\UserManagerInterface;
use DrevOps\BehatSteps\Behat\ParametersAwareInterface;

/**
* Injects the driver manager and its collaborators into a context.
*/
class DriverAwareInitializer implements ContextInitializer {

/**
* Constructs a DriverAwareInitializer object.
*
* @param \DrevOps\BehatSteps\Behat\Manager\DriverManagerInterface $driverManager
* The driver manager.
* @param array<string, mixed> $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);
}

}
Loading