Skip to content

V4 Epic #773

Description

@AlexSkrypnyk

behat-steps v4 absorbs drupal/drupal-driver and drupal/drupal-extension, making drevops/behat-steps the complete Drupal-Behat testing stack: driver, Behat integration and step vocabulary in 1 package, 1 repository, 1 release line.

Terminology: in this document "v4" always means the behat-steps package major; the Behat framework's own next major is always written "Behat 4". They are unrelated version lines that happen to land in the same period.

Sources analysed: jhedstrom/DrupalDriver (at e7efa1e, the 4.x-direction branch with Drupal 10 and PHP 8.2 support removed), jhedstrom/drupalextension (main at 773601d, the v6.1 line), drevops/behat-steps (main at 6464d35, the v3 line).

Why

The maintenance burden across 3 repositories is the driver for this change. Every substantive improvement today ripples through 3 release lines that must be coordinated by hand: drupal-extension pins drupal/drupal-driver ^3.2, behat-steps conflicts drupal-extension <6, and testing the stack together requires cross-repo CI smoke jobs with path-repository version-shape detection. The repos duplicate their tooling (2 independent docs.php generators enforcing 2 different step conventions), duplicate their runtime machinery (2 entity-cleanup registries that must know about each other to avoid double-deleting), and ship 2 competing step grammars that consumers register side by side. behat-steps shadow-implements driver internals that break silently on upstream change, and its OverrideTrait exists purely to fight drupal-extension - its own docblock calls it fragile. On top of the technical burden sits an ownership asymmetry: 2 of the 3 repos live under another user's account while the maintenance falls here.

The timing makes consolidation cheaper now than later: Behat 4 is imminent (its alpha shipped in June 2026) and Drupal 12 is on the horizon. Meeting them across 3 coordinated majors means doing every compatibility job 3 times; meeting them in 1 tree means doing each job once.

Section 1.5 lists the full inventory of frictions this merge deletes.

Decisions

  • Everything moves into drevops/behat-steps, released as v4. The repository, the Packagist name and the GitHub history stay; the package becomes the full stack. Consumers bump ^3 to ^4 on a package they already require, and Renovate proposes it like any other major. The pointer-and-abandon work shrinks to the 2 jhedstrom packages.
  • Clean break inside a familiar root. The internal layout is rebuilt as DrevOps\BehatSteps\{Driver, Behat, Steps} (section 2.3); the old Drupal\Driver\* and Drupal\DrupalExtension\* namespaces retire. No compatibility shims - migration guides and Rector sets instead.
  • Platform floors move with the merge: v4 requires PHP 8.3+, Drupal 11+ and behat/behat ^3.32 || ^4.0. The current v3 minor becomes LTS: supported until 1 July 2027 with bugfixes and security updates only, which keeps Drupal 10 / PHP 8.2 projects covered.
  • License stays GPL-2.0-or-later. All 3 sources are GPL-2.0-or-later and the merged work derives from them, so relicensing is not on the table.
  • The name trade-off is accepted: behat-steps will label more than steps. The name is already Drupal-free, the accumulated equity (downloads, stars, SEO, consumer trust) outweighs the mismatch, and renaming a Packagist package later is exactly the abandon-and-replace dance this decision avoids.

1. The 3 codebases today

1.1 drupal/drupal-driver (3.x heading to 4.x)

PSR-0, Drupal\Driver, ~69 classes. Requires only drupal/core-utility ^11 + symfony/dependency-injection + symfony/process, PHP ^8.3. Deliberately Behat-free.

The architecture is the newest thinking of the 3 repos: a minimal DriverInterface (bootstrap(), isBootstrapped(), getRandom()) plus 13 opt-in capability interfaces (12 operational + creation aliases), so instanceof is the runtime feature probe. BlackboxDriver declares 0 capabilities, DrushDriver 7, DrupalDriver all of them. DrupalDriver holds no Drupal logic - every method forwards to a CoreInterface, with a Core{N} version-lookup chain as the Drupal 12 seam. The field subsystem is the crown jewel: 23 concrete handlers + a classifier-gated DefaultHandler fallback, discovered by filename convention, extensible at runtime via registerFieldHandler(), with EntityStub as the typed value envelope and a creation-alias system (author, vocabulary_machine_name, roles) on top.

Debt worth noting: PSR-0 layout with a load-bearing dual autoload mapping, 4 handlers already marked deprecated, branch-alias still says 3.0.x-dev, Rector still on the PHP 8.2 set, UnsupportedDriverActionException exists but is never thrown (login/logout silently no-op on a non-auth Core), and behat/mink sits unused in require-dev.

1.2 drupal/drupal-extension (v6.1)

PSR-0, Drupal\DrupalExtension, type behat-extension. Requires behat/behat ^3.22, Mink + BrowserKit + friends-of-behat/mink-extension (subclassed to add a BrowserKit factory around Drupal's DrupalTestBrowser), and drupal/drupal-driver ^3.2. PHP >=8.2.

3 jobs in 1 package: (1) the Behat ServiceContainer extension - config schema (api_driver, drivers, regions, text, selectors, mappings), compiler passes, context initializer injecting driver manager + auth/user/mail managers; (2) RawDrupalContext - auth lifecycle, entity creation with hook dispatch (#[BeforeNodeCreate] attribute style since v6) and AfterScenario cleanup of everything it created; (3) 11 shipped contexts full of step definitions (DrupalContext, MinkContext, MarkupContext, MessageContext, MailContext, ConfigContext, DrushContext, RandomContext, MappingContext, ...). v6 also added the EntityFieldParser - a real parser for compound field cells with caret-pointing errors - which imports nothing from Behat, only the driver's FieldClassifierInterface.

The dominant coupling to the driver is EntityStubInterface (33 references) plus capability instanceof probes. 2 gaps force workarounds: processBatch() is in no capability interface (so method_exists() probing), and driver bootstrap is lazy (only getDriver() triggers it).

1.3 drevops/behat-steps (v3)

PSR-4, DrevOps\BehatSteps, and the only repo of the 3 that is pure traits: 51 traits, 373 step definitions, 0 classes. Hard-requires only behat/behat + behat/mink; DrupalExtension arrives via suggest + conflict: <6. 18 generic Mink traits (element, field, link, table, JSON, XML, cookie, path, response, ...) work on any site; 23 Drupal traits call \Drupal:: statics directly and assume a DrupalExtension context base underneath; 6 infrastructure traits (diagnostics, JS-error capture, waits, date transforms, shell commands).

The v3 grammar is the strictest of the ecosystem: tuple placeholders only, no regex steps, subject-first Then ... should, trait-prefixed method names, all machine-enforced by docs.php which also generates the 6,390-line STEPS.md. Lifecycle integration is attribute hooks with a @behat-steps-skip:<method> opt-out convention and 2 cleanup mechanisms: snapshot/restore traits (config, state, modules, time) and a unified entity registry deleting in reverse creation order - with an exclusion list so it doesn't double-delete what DrupalExtension's own teardown owns.

Debt worth noting: OverrideTrait exists solely to rewrite DrupalExtension's steps and to hack around lazy driver bootstrap; Drupal\HelperTrait shadow-implements 2 pieces of driver internals (the FileHandler fixture-path semantics and the parser's compound-cell regex); BigPipeTrait name-collides with DrupalExtension's own.

1.4 How they couple

                     consumer FeatureContext
              extends DrupalContext, uses ~50 traits
                   │                          │
      drevops/behat-steps v3 ─────────────────┤
      51 traits, 373 steps, PSR-4             │
        │ requires behat + mink only          │
        │ inherits getDriver()/managers ──────┤
        │ 23 Drupal traits call \Drupal:: directly (bypass the driver)
        │ shadows FileHandler + parser cell syntax
        ▼
      drupal/drupal-extension v6 ── friends-of-behat/mink-extension ── Mink drivers
      extension + 11 contexts + 4 managers + hooks, PSR-0
        │ requires drupal/drupal-driver ^3.2
        │ 33 refs to EntityStubInterface, capability instanceof probes
        ▼
      drupal/drupal-driver 3.x/4.x, PSR-0
      3 drivers × 13 capabilities, Core (+ Core{N} seam),
      23+1 field handlers, classifiers, aliases, EntityStub
        │
        ▼
      Drupal 11 site (in-process bootstrap or Drush)

1.5 The frictions v4 deletes

  1. 2 step grammars for the same assertions. DrupalExtension's contexts speak I should see ...; behat-steps speaks the element ... should .... Consumers register both today - behat-steps' own behat.yml does exactly that - so every project carries 2 vocabularies.
  2. 2 entity-cleanup registries that must know about each other: behat-steps maintains ENTITY_CLEANUP_EXCLUDED_TYPES purely to avoid double-deleting what DrupalExtension's teardown owns.
  3. Shadowed internals. behat-steps re-implements the driver's file-resolution semantics and the parser's compound-cell regex; any upstream change breaks it silently.
  4. OverrideTrait exists only to rewrite DrupalExtension steps and hack lazy bootstrap - pure friction between the 2 packages, self-documented as fragile.
  5. BigPipeTrait × 2 - a live name collision, dodged by renaming a hook method.
  6. 2 independent docs generators (scripts/docs.php in both DrupalExtension and behat-steps) enforcing 2 different conventions.
  7. 3-repo release coordination: DrupalExtension pins drupal-driver ^3.2, behat-steps conflicts drupal-extension <6, and cross-repo CI smoke jobs need path-repository version-shape detection to test the stack together.
  8. PSR-0 vs PSR-4 split across the stack.
  9. Capability gaps force method_exists() probes and silent no-ops instead of the typed UnsupportedDriverActionException that already exists.
  10. The driver abstraction leaks: 23 step traits reach for \Drupal:: directly because the layering never defined a sanctioned way to get a bootstrapped Drupal.

2. Target architecture

Everything below lands in drevops/behat-steps v4. Designed against the current driver 4.x direction: Drupal 11+ with a Drupal 12 seam, PHP 8.3+, PSR-4, deprecated handlers removed, Behat 3 now with a clean path to Behat 4.

2.1 The usage tiers the design serves

Every element of this architecture has to justify itself against a real usage pattern, from running someone else's suite to building an ecosystem package on top. The tiers below are the review lens: each public surface names the tier it serves, and anything serving no tier gets cut.

Tier Who and what they do What serves them
T0 Runner Runs an existing suite (CI, new team member); reads failures, not code Stable vocabulary, STEPS.md, diagnostics on failed steps, deterministic cleanup
T1 Zero-config author Writes features using only the shipped vocabulary; touches no PHP Curated DrupalContext, quick-start behat.php, sensible defaults
T2 Composing author Owns a FeatureContext; picks traits per project; toggles behaviour Trait granularity, the hygiene rule, tags, extension config
T3 Domain-step author Writes project- and domain-language steps that reuse the library's helpers Single-$this trait model, documented protected helper API, steps/helpers separation
T4 Overrider Disables or replaces shipped behaviour Unified skip-tag convention, trait-method precedence in the using class, configurable selectors/text
T5 Platform integrator Custom field handlers, creation aliases, Cores, drivers, auth Driver extension points, capability interfaces, registerFieldHandler() and classifier seams
T6 Ecosystem builder Ships their own trait pack or extension on top (behat-screenshot-style) Stable RawContext contracts, wrapper-context pattern, semver discipline

The frictions in 1.5 re-read as tier failures: T2/T3 being served by 2 packages produced OverrideTrait and the shadow parser; T1 being served by 2 grammars produced the dual vocabulary; T4 conventions diverging produced the inconsistent skip tags. The merge is justified exactly because 1 package can serve every tier coherently.

Upstream alignment check (docs.behat.org, read 2026-09-06; the docs are already rewritten for the Behat 4 era). Verdict first: the behat-steps model is not "doing it wrong" - its mechanics are documented usage, and the v3/v6 lines are ahead of the wider ecosystem on the Behat 4 breaks - but upstream's organizing axis and its pedagogy differ from current practice in 2 places worth adopting deliberately rather than by accident.

Where the docs validate the current model:

  • Traits are explicitly documented for step reuse - "step definitions placed in a trait work when the trait is used in a context class" - with 1 caveat: a redundancy error when 2 registered contexts carry the same definitions. Principle 2's hygiene rule plus the single-FeatureContext model are exactly the documented safe usage.
  • Attributes for definitions, hooks and transforms are the documented mechanism, and annotations are gone in Behat 4; both current libraries already comply.
  • Turnstile :token patterns lead the docs (regex remains supported); behat-steps' no-regex mandate is a stricter subset of documented usage.
  • Tags are upstream's blessed organization tool; the heavy tag conventions are idiomatic.
  • Definition uniqueness is upstream law (the Redundant exception) - which both explains the historical OverrideTrait and duplicate-step pain of running 2 vocabularies, and endorses the single-grammar merge plus the composer conflict block.
  • Per-scenario context isolation (fresh context objects for every scenario) is the documented state model the cleanup registries already assume.

Where current practice diverges from upstream's intent:

  • Suites are underused across the whole Drupal-Behat culture. Upstream's modularity axis is the suite - each with its own paths, filters, context list and per-context constructor parameters - while all 3 repos and their consumers run 1 suite and slice everything with tags. The v4 docs should teach suite-per-surface as the default layout - a blackbox suite, an api suite, a javascript suite - which also hands T2 authors the per-suite configuration channel upstream documents.
  • Upstream pedagogy is domain language; this library is infrastructure vocabulary. The quick start teaches declarative business-outcome scenarios, not generic UI steps. A 373-step generic vocabulary serves functional regression testing of CMS builds - a legitimate and huge use case - but it is a different methodology from the one Behat teaches. The design answer is to make T3 first-class: the docs celebrate writing thin domain-language steps on top of the library's protected helpers, with the generic vocabulary positioned as scaffolding, regression coverage and plumbing inside those definitions. The '2 styles, 2 jobs' documentation page carries the full argument and ships via its own sub-issue.

2.2 Principles

  1. 3 layers, 1 dependency direction: Steps → Behat → Driver, never upward. The Driver layer references nothing from Behat or Mink, enforced as a CI dependency rule (a PHPStan rule or deptrac layer check) - the driver stays usable without Behat loaded.
  2. Traits are the only step-delivery mechanism; contexts are thin. Users compose their FeatureContext from traits; contexts that remain hold lifecycle, not vocabulary.
    • Corollary: step traits never use other step traits. Shared logic lives in step-free helper traits or services. This kills the trait-collision fatals (the BigPipe incident) and keeps every trait independently composable - which also keeps generated per-domain context wrappers possible later.
  3. 1 grammar. The behat-steps v3 conventions (tuple placeholders, no regex, subject-first Then, trait-prefixed methods) become the only grammar, enforced by 1 docs generator/linter. DrupalExtension's step definitions are re-expressed in that grammar or dropped.
  4. The parser belongs to the driver. EntityFieldParser is already Behat-free (verified: it imports only FieldClassifierInterface and its own exceptions), so it moves next to the field handlers. The driver understands compound cell syntax natively, and the v3 shadow copy dies.
  5. 1 entity registry, 1 cleanup pass. RawContext owns the creation registry (merging DrupalExtension's $createdStubs with v3's entityRegister()), deletes in reverse creation order, and the double-delete exclusion list becomes unnecessary.
  6. Capabilities over probes. Add BatchCapabilityInterface (killing the method_exists('processBatch') probe); login()/logout() on a non-auth Core throw UnsupportedDriverActionException instead of silently no-opping.
  7. An explicit bootstrap contract. 1 sanctioned gateway on RawContext (working name drupal()) ensures the API driver is bootstrapped and returns it, or throws a named error. Steps\Drupal\* traits use it instead of raw \Drupal:: reach-through, and OverrideTrait's bootstrap hack disappears. Which traits need the in-process driver vs mere capabilities becomes documented API.
  8. Attributes everywhere. Hooks, transforms and steps already use PHP attributes in the current v6/v3 lines; that's the Behat 4 posture (confirmed: Behat 4 removes annotation support outright - see 2.10).
  9. PSR-4 throughout, 1 namespace root - DrevOps\BehatSteps\ - killing the PSR-0 depth and the dual autoload mapping.
  10. The version seams stay: the Core{N} lookup chain and per-version classifier factories are the Drupal 12 insurance.
  11. GPL-2.0-or-later stays. The merged work derives from GPL-2.0-or-later sources.
  12. Every step body is a thin wrapper over a named protected helper. The package is 2 products in 1 - the vocabulary (steps) and the toolbox (helpers) - and the helper surface is documented, semver-covered public API (the '2 styles, 2 jobs' docs page).

2.3 Namespace and package layout

drevops/behat-steps (v4)
└─ src/
   ├─ Driver/                     Behat-free, Mink-free (enforced)
   │  ├─ DriverInterface.php      bootstrap(), isBootstrapped(), getRandom()
   │  ├─ BlackboxDriver.php       0 capabilities (negative guarantee)
   │  ├─ DrupalDriver.php         in-process bootstrap, delegates to Core
   │  ├─ DrushDriver.php          shells out via symfony/process
   │  ├─ Capability/              14 capability interfaces (13 today + Batch)
   │  ├─ Core/                    CoreInterface, Core, Core{N} lookup seam
   │  ├─ Field/                   handlers, classifiers, DefaultHandler gate
   │  │  └─ Parser/               EntityFieldParser + exceptions (moved in)
   │  ├─ Alias/                   creation aliases (pre/post, registry trait)
   │  ├─ Entity/                  EntityStub, EntityStubInterface
   │  └─ Exception/
   ├─ Behat/                      the integration (ex DrupalExtension)
   │  ├─ ServiceContainer/        BehatStepsExtension, config schema, DriverPass
   │  ├─ Mink/                    Mink glue: extension, driver factories, BrowserKitFactory,
   │  │                           DocumentElement (absorbed - see 2.11)
   │  ├─ Manager/                 DriverManager, AuthenticationManager, UserManager, MailManager
   │  ├─ Context/                 RawContext (lifecycle only), DrupalContext (curated default),
   │  │                           DriverAwareInterface, initializer, ClassGenerator
   │  ├─ Hook/                    attributes → calls → scopes (entity/node/term/user/language)
   │  ├─ Listener/                DriverListener (per-scenario driver selection)
   │  └─ Selector/                RegionSelector
   └─ Steps/                      the vocabulary (traits only, 1 grammar)
      ├─ Generic/                 ~24 traits: Element, Field, Link, Table, Json, Xml, Cookie,
      │                           Path, Response, Keyboard, Iframe, Modal, Metatag, Responsive,
      │                           FileDownload, Accessibility, Dropzone, Rest, Wait, Command,
      │                           Date, Diagnostics, Javascript, Message, Region, Random, Mapping
      └─ Drupal/                  ~27 traits: Content, User, Taxonomy, Block, ContentBlock, Cache,
                                  Config, ConfigOverride, Eck, Email, File, Media, Menu, Module,
                                  Paragraphs, Queue, Redirect, SearchApi, State, Time, Watchdog,
                                  Webform, Draggableviews, BigPipe, Batch, Testmode, Drush

How contexts and traits compose. RawContext ships 0 step definitions - it's pure lifecycle: driver access, the drupal() bootstrap gateway, auth delegation, entity-creation helpers, the single creation registry + cleanup, and hook dispatch. The consumer's FeatureContext extends it and imports only the traits that project wants - the v3 model promoted to the whole stack. DrupalContext is the optional zero-config on-ramp: RawContext plus a curated default set of the broadly-safe traits. Nothing imports all traits by default, because ~10 of them carry hooks that change scenario outcomes rather than add vocabulary (Watchdog fails scenarios on logged PHP errors, Javascript on console errors, Accessibility runs axe scans, BigPipe inserts per-step waits, Diagnostics rewrites failure messages) - those stay opt-in per project, though an everything-at-once composition remains possible. Traits-in-one-object beats many registered contexts because Behat contexts are separate objects: sharing state across them needs the getContext() environment-walking gymnastics DrupalExtension has today, while traits share 1 $this - same session, driver, parameters and entity registry for free.

Decision record: why traits, not contexts, for the vocabulary. Converting the step packs to registrable context classes was analysed and rejected. What contexts would buy: YAML-only composition per suite, per-suite typed constructor configuration (a real advantage - see the config-channels note below), explicitly declared dependencies instead of implicit $this assumptions (largely neutralised by @phpstan-require-extends on traits), no PHP trait-collision fatals, cleaner standalone static analysis, and ecosystem-native packaging. What contexts would cost: the single-inheritance wall - a consumer's own step class could no longer call the packs' protected helpers, killing the build-on-top pattern v3 users rely on daily; every cross-pack interaction (shared entity registry, helper reuse) becomes a service or getContext() environment lookup; the migration becomes a model change instead of a namespace re-rooting for the v3 audience; and the duplicate-registration trap (shipped context + consumer subclass both registered) reintroduces OverrideTrait-class pain. Packaging conveniences versus composition capabilities - composition wins for a library whose consumers extend it in the same class they write steps in. The step-traits-never-use-step-traits rule (principle 2) captures the best of the context model and leaves generated thin context wrappers (ContentContext extends RawContext { use ContentTrait; }) available as a non-breaking 4.x addition.

Config channels, precisely. 4 ways settings reach step code, with different granularity: (1) context constructor arguments - resolved by Behat, per suite, typed, placeholder-aware, and the constructor signature self-documents the pack's options; contexts own this slot, and traits only reach it through plumbing the consumer writes in their own FeatureContext constructor (Behat passes constructor args to any registered context, including theirs). (2) The extension config key - per profile, one schema, delivered to traits via the initializer; this is already how DrupalExtension configures its own step behaviour (ajax_timeout, login_wait, regions, selectors, text). (3) Tags - per feature/scenario, identical in both models. (4) Env vars - per run. The trait model therefore covers per-profile and per-scenario configuration natively and per-suite only via consumer plumbing. Practical weight is limited - the current traits' config appetites (accessibility threshold and report dir, breakpoints, download dir, wait timeouts) are per-project constants or per-scenario overrides - but where per-suite config matters, the wrapper contexts are the designated carrier: a generated wrapper with a real constructor exposes that pack's options per suite, typed and autocompleted under Behat 4's behat.php, without abandoning trait composition as the substance.

2.4 Runtime class relationships

consumer FeatureContext
  extends Behat\Context\RawContext        uses Steps\Generic\*, Steps\Drupal\*
             │
             ├─ Manager\DriverManager ───── Driver\DriverInterface
             │        │                        ├─ BlackboxDriver   (0 capabilities)
             │        │                        ├─ DrushDriver      (7 capabilities)
             │        │                        └─ DrupalDriver     (all capabilities)
             │        │                               └─ Core\CoreInterface
             │        │                                     ├─ Field\ handlers + classifiers
             │        │                                     ├─ Field\Parser\EntityFieldParser
             │        │                                     └─ Alias\ creation aliases
             │        └─ lazy bootstrap + per-scenario selection (Listener\DriverListener)
             ├─ Manager\{Authentication,User,Mail}Manager
             ├─ 1 entity registry → reverse-order AfterScenario cleanup
             └─ Hook dispatch: #[BeforeEntityCreate] etc. → scopes carrying EntityStub

All namespaces above are relative to DrevOps\BehatSteps\.

2.5 Where every existing class lands

From drupal/drupal-driver - everything survives, shapes unchanged, namespace only:

Today v4 home
Drupal\Driver\* (drivers, capabilities, Core, field, aliases, EntityStub, exceptions) DrevOps\BehatSteps\Driver\*
TextHandler, TextLongHandler, TextWithSummaryHandler, ColorFieldTypeHandler (deprecated) Deleted

From drupal/drupal-extension:

Today v4 home
ServiceContainer\DrupalExtension + Compiler\DriverPass Behat\ServiceContainer\* (config schema carried over nearly unchanged; extension class BehatStepsExtension)
Drupal\MinkExtension\* (subclass, BrowserKitFactory) + Element\DocumentElement Behat\Mink\*
Manager\{DriverManager, DrupalAuthenticationManager, DrupalUserManager, DrupalMailManager} + interfaces Behat\Manager\*, dropping the Drupal prefixes
Context\RawDrupalContext Behat\Context\RawContext - lifecycle only; its step methods move to traits
Context\DrupalContext step definitions Re-expressed in Steps\Drupal\{Content,User,Taxonomy,Cache,...}Trait
Context\MinkContext step definitions Steps\Generic\*; region handling becomes Steps\Generic\RegionTrait
MarkupContext Merged into Steps\Generic\ElementTrait
MessageContext Steps\Generic\MessageTrait (selectors stay configurable)
MailContext / RawMailContext Merged into Steps\Drupal\EmailTrait on top of Behat\Manager\MailManager
ConfigContext Merged into Steps\Drupal\ConfigTrait
DrushContext Steps\Drupal\DrushTrait
RandomContext / MappingContext (transforms) Steps\Generic\RandomTrait / MappingTrait
Context\Traits\{AjaxTrait, BasicAuthTrait, BatchTrait, BigPipeTrait} Merged into Steps\Generic\WaitTrait, Steps\Generic\BasicAuthTrait, Steps\Drupal\BatchTrait, Steps\Drupal\BigPipeTrait
Hook\* (attributes, calls, scopes) Behat\Hook\* unchanged
Context\Initializer\DrupalAwareInitializer + DrupalAwareInterface Behat\Context\Initializer\* as DriverAwareInitializer / DriverAwareInterface
Listener\DriverListener, Selector\RegionSelector, Generator\ClassGenerator Behat\{Listener,Selector,Generator}\*
Parser\EntityFieldParser + exceptions Driver\Field\Parser\* - the headline move
ParametersTrait, MinkAwareTrait, RegionTrait, TagTrait, FeatureTrait, ScenarioTrait Behat\* support traits
DeprecationInterface/DeprecationTrait (no live call sites), EventSubscriberPass (dormant), Environment\Reader (no-op), stray TermScope file, vestigial Drupal\Exception autoload entry Deleted

From behat-steps v3 itself - the internal re-rooting:

Today v4 home
DrevOps\BehatSteps\<X>Trait (18 generic + 6 infrastructure) DrevOps\BehatSteps\Steps\Generic\<X>Trait
DrevOps\BehatSteps\Drupal\<X>Trait (23) DrevOps\BehatSteps\Steps\Drupal\<X>Trait
HelperTrait (internal) Stays internal under Steps\
Drupal\HelperTrait fixture-path expansion + compound-cell shadow regex Deleted - the driver's FileHandler + relocated parser serve it directly
Drupal\HelperTrait entity registry Absorbed into Behat\Context\RawContext's single registry
OverrideTrait Deleted - nothing left to override, the bootstrap contract makes the hack obsolete
BigPipeTrait Merged with DrupalExtension's into 1 trait
docs.php + STEPS.md generation and step-format linting The 1 docs toolchain for the whole vocabulary

2.6 The duplicate-step merge worklist

This is the real content work of the merge. Resolution rule: the v3 grammar wins; functionality unique to the DrupalExtension side gets absorbed into the surviving trait.

Overlap Today Survivor
Mail assertions MailContext vs EmailTrait (21 steps) Steps\Drupal\EmailTrait
Config set/revert ConfigContext vs ConfigTrait Steps\Drupal\ConfigTrait
Node creation/visits DrupalContext vs ContentTrait Steps\Drupal\ContentTrait
Users and roles DrupalContext vs UserTrait Steps\Drupal\UserTrait
Taxonomy DrupalContext vs TaxonomyTrait Steps\Drupal\TaxonomyTrait
Cache and cron DrupalContext vs CacheTrait Steps\Drupal\CacheTrait
Current path MinkContext vs PathTrait Steps\Generic\PathTrait
Response headers MinkContext vs ResponseTrait Steps\Generic\ResponseTrait
AJAX and waits AjaxTrait vs WaitTrait Steps\Generic\WaitTrait (keeping the before/after-step JS waits)
Key presses MinkContext vs KeyboardTrait Steps\Generic\KeyboardTrait
Table assertions DrupalContext table-row steps vs TableTrait Steps\Generic\TableTrait
BigPipe 2 colliding traits 1 Steps\Drupal\BigPipeTrait

2.7 composer.json shape

{
  "name": "drevops/behat-steps",
  "type": "library",
  "license": "GPL-2.0-or-later",
  "require": {
    "php": ">=8.3",
    "behat/behat": "^3.32 || ^4.0",
    "behat/gherkin": "^4.13",
    "behat/mink": "^1.12",
    "behat/mink-browserkit-driver": "^2.1",
    "drupal/core-utility": "^11",
    "symfony/css-selector": "^6.4 || ^7",
    "symfony/dependency-injection": "^6.4 || ^7",
    "symfony/dom-crawler": "^6.4 || ^7",
    "symfony/http-client": "^6.4 || ^7",
    "symfony/process": "^6.4 || ^7",
    "webflo/drupal-finder": "^1.3"
  },
  "conflict": {
    "drupal/drupal-driver": "*",
    "drupal/drupal-extension": "*"
  },
  "suggest": {
    "drush/drush": "^13 - required by the Drush driver",
    "friends-of-behat/mink-extension": "Interop seam for third-party Mink driver factories",
    "justinrainbow/json-schema": "JSON Schema assertions in JsonTrait",
    "softcreatr/jsonpath": "JSONPath assertions in JsonTrait",
    "lullabot/mink-selenium2-driver": "@javascript scenarios via Selenium",
    "dmore/behat-chrome-extension": "@javascript scenarios via Chrome DevTools"
  },
  "autoload": { "psr-4": { "DrevOps\\BehatSteps\\": "src/" } }
}

Notes: the conflict block prevents the 2 old packages from co-registering duplicate steps; lullabot/mink-selenium2-driver demotes from require (where DrupalExtension has it today) to suggest since it only serves @javascript scenarios; Drush 13+ only, which also drops the pre-Drush-12 output-parsing compat code; friends-of-behat/mink-extension moves to suggest because the Mink glue ships in-tree (2.11); Symfony constraints widen to ^8 once Behat 4 final and Drupal 12 are both testable.

2.8 behat.yml and behat.php shape

The DrupalExtension v6 config schema is proven and carries over nearly unchanged - only the extension class changes:

default:
  extensions:
    DrevOps\BehatSteps\Behat\Mink\MinkExtension:
      base_url: http://nginx:8080
      browserkit_http: ~
    DrevOps\BehatSteps\Behat\ServiceContainer\BehatStepsExtension:
      api_driver: drupal
      drivers:
        drupal:
          drupal_root: web
        drush:
          root: web
      regions:
        header: '.layout-header'
      selectors:
        messages:
          error: '.messages--error'
      mappings:
        content:
          Site name: 'My site'

Behat 4 removes YAML configuration entirely in favour of behat.php (the PHP config format that exists opt-in since the late Behat 3 line), so the docs ship both formats during the dual-support window with behat.php as the primary - and it opens the door to a typed fluent config builder for the extension's settings instead of stringly YAML. The rewritten upstream docs confirm extensions receive their settings as a plain array via new Extension(BehatStepsExtension::class, [...]), so the existing config schema survives the format change unchanged.

2.9 Tests and CI

  • tests/Unit/ - driver unit tests, handler tests (reflection-instantiated), parser tests, trait logic tests. tests/Kernel/ - the SQLite kernel suite from DrupalDriver, including the field-type coverage safety net. tests/Behat/ - blackbox profile against phpserver-served static fixtures (generic traits), drupal profile against the fixture site (Drupal traits + api driver), plus the behat-in-behat CLI harness from v3 that proves each trait in isolation.
  • The 3 fixture ecosystems merge: DrupalDriver's driver_field_test module, DrupalExtension's behat_test module and v3's fixture site + mysite_core become 1 fixture module set on 1 scaffolded site.
  • The cross-repo smoke jobs and their path-repository version-shape detection die - the whole stack tests in-tree on every commit.
  • Matrix: 1 lint job (PHP 8.4), then PHP 8.3 / 8.4 / 8.5 × Drupal 11 × normal / lowest deps, + 2 chrome_headless legs for driver portability, + 1 Behat 4 leg (alpha now, final when it ships) alongside the Behat 3.32 floor the lowest legs already exercise, + Drupal 12 legs the day they're testable, with coverage gated on 1 leg. Roughly 10 jobs replacing the ~28 the 3 repos run today. Behat self-tests run in Gherkin 3.2 parsing mode from day 1 (see 2.10).

2.10 Behat 4 readiness and opportunities

Status, checked 2026-09-06: Behat 4.0.0-alpha1 shipped in June 2026 (2 days after 3.32.0), the final is pencilled in for Q3 2026 - inside this project's likely build window - and the stated driver is Symfony 8 support. The alpha is described by the maintainers as essentially stable, carrying the primary breaking changes that affect extension authors. Behat 3.x keeps bugfixes for 12 months and security fixes for 24 months after Behat 4 ships.

What Behat 4 changes, and what each change costs this project:

  • Annotation-based steps/hooks/transforms are removed; attributes are the only mechanism. Costs nothing here - DrupalExtension v6.1 and behat-steps v3 are already attributes-only, so v4 inherits full readiness for the single biggest break.
  • YAML configuration is removed; behat.php becomes the format. Docs and examples ship both during the transition (see 2.8); the typed config builder is the upside.
  • Gherkin 3.2 parsing mode becomes the default (opt-in exists since Behat 3.32). Running the ~1,100 self-test scenarios under that mode from day 1 makes the default flip a non-event.
  • Strict types throughout, non-public API classes made final, the Extension interface decoupled from third-party interfaces, and ScenarioLikeTested split into separate events. This targets extension authors - us. Audit list for the Behat layer: DriverListener (subscribes to scenario/example tested events), the context.class_generator.simple service override behind ClassGenerator, HookAttributeReader (the context.attribute_reader tag that powers the custom #[BeforeEntityCreate] family), the context initializer, and the fate of Behat 3 suite-level helper containers (shared services between contexts), which the rewritten docs no longer feature.
  • Behat 3.32 also added a deprecation collector with fail-on-deprecation tester options - the official replacement for the homegrown DeprecationTrait this plan deletes.

How v4 benefits:

  • Ship Behat-4-ready at v4.0.0 with behat/behat: ^3.32 || ^4.0 and CI legs on both - likely the first Drupal Behat stack that is, while the old repos would each need their own major to get there. A launch story, not just hygiene.
  • 1 migration event instead of 3: consumers still on annotation-era DrupalExtension fold annotations-to-attributes, YAML-to-behat.php and the package swap into a single guided move; the planned Rector set covers all of it (Behat ships an annotations-to-attributes rule already).
  • Symfony alignment happens once: the driver and Behat layers share 1 repo, so widening symfony/* to ^8 (needed by Behat 4, likely needed by Drupal 12) is 1 change, not 3 coordinated releases.
  • External dependency state: friends-of-behat/mink-extension shipped v3.0.0-ALPHA.1 in July 2026 with behat ^3.32 || ^4.0, PHP ^8.3 and Symfony ^7.4 || ^8.0, so Behat 4 compatibility exists upstream - but the project pledges no updates beyond Symfony compatibility, which keeps it a strategic liability even while it works. 2.11 absorbs the glue at v4.0.0.

Relation to the traits-vs-contexts decision (2.3): neutral. Behat has no concept of traits - PHP flattens them into the class and Behat reflects attributes off whatever contexts are registered - so Behat 4 changes nothing about step delivery and neither rewards nor punishes either model. The 1 touchpoint is configuration ergonomics: behat.php makes per-context constructor arguments typed and autocompleted, strengthening the context model's per-suite config advantage - which the generated wrapper contexts (2.3) are designated to carry. The composition trade-offs that decided 2.3 are untouched.

2.11 The Mink stack: what stays upstream, what we absorb

The Mink dependency is really 4 layers, and the answer differs per layer:

Layer Package(s) Verdict
Browser abstraction (Session, NodeElement, WebAssert, the driver API) behat/mink Keep - maintained under the Behat org, and reimplementing it means reimplementing the browser drivers
Drivers behat/mink-browserkit-driver, lullabot/mink-selenium2-driver, optionally dmore/behat-chrome-extension Keep - driver maintenance is the expensive, thankless part and none of it differentiates this stack
Behat-to-Mink glue friends-of-behat/mink-extension Absorb at v4.0.0 - see below
Generic step vocabulary MinkContext's steps Already replaced by Steps\Generic\* traits in this design

The state of the glue package, checked 2026-09-06: v3.0.0-ALPHA.1 (July 2026) targets behat ^3.32 || ^4.0, PHP ^8.3 and Symfony ^7.4 || ^8.0 - so Behat 4 compatibility exists - but the project explicitly pledges no updates other than Symfony compatibility, 519 packages depend on it, and its bundled MinkContext is precisely the second grammar this design retires. A foundational dependency in declared minimal-maintenance mode is a liability even while it currently works.

Absorbing it is small, because DrupalExtension already owns half of it: the subclassed MinkExtension class, the BrowserKitFactory, the DocumentElement alias and the MinkAwareTrait reimplementation all sit in the v6 tree today. The remaining delta: the mink config schema (sessions, base_url, browser_name, files_path), driver factories for the 2-3 drivers this stack supports, and a RawMinkContext equivalent worth roughly 150 lines (getSession(), assertSession(), visitPath(), getMinkParameter()). The step traits' 410 getSession() calls touch nothing deeper.

Why the decision belongs at v4.0.0 and not later: RawContext's ancestry is public API - consumers type-hint and instanceof against it - so swapping its parent class in a 4.x release would be a break. The clean-break major is the one cheap moment to own the base class outright.

The interop cost and its bridge: third-party packages type against Behat\MinkExtension\* - notably dmore/behat-chrome-extension registers a driver factory into the upstream extension, and the in-family drevops/behat-screenshot and drevops/behat-phpserver reference its context types. The in-family ports are ours to make; for everything else, keep friends-of-behat/mink-extension as a suggest-level interop seam: the extension defines its own factory interface and ships an adapter that accepts upstream DriverFactory implementations when that package happens to be installed.

3. Migration

Audience What changes Tooling
behat-steps v3 consumers Constraint bump ^3 to ^4; trait imports re-root (DrevOps\BehatSteps\ElementTrait becomes DrevOps\BehatSteps\Steps\Generic\ElementTrait); FeatureContext base becomes DrevOps\BehatSteps\Behat\Context\RawContext (or DrupalContext); behat.yml swaps the DrupalExtension entry for BehatStepsExtension Rector set + migration guide; mechanical
drupal-extension v5/v6 consumers Require drevops/behat-steps ^4 instead; Drupal\DrupalExtension\* references re-root; retired context step texts map to the v4 vocabulary via a published step-mapping table; v5-era annotation users convert to attributes on the way (Behat ships the Rector rule) Rector set + guide + step-mapping table
drupal-driver standalone consumers Require drevops/behat-steps ^4; Drupal\Driver\* becomes DrevOps\BehatSteps\Driver\*; shapes unchanged. Behat arrives as a dependency they don't invoke - accepted single-package trade-off Rector set; mechanical

Platform floors: v4 = PHP 8.3+, Drupal 11+, behat ^3.32 || ^4, Drush 13+ (optional). The current v3 minor is LTS until 1 July 2027 with bugfixes and security updates only, keeping Drupal 10 / PHP 8.2 projects covered.

4. Rollout plan

  1. Groundwork: final v3 minor release; announce the v4 direction in the repo (this plan becomes the epic); reserve nothing - the repo and package already exist.
  2. Tree baseline: bring the merged tree's scaffolding up before the code lands - CI skeleton, lint stack, Renovate. The driver and extension code is re-used directly (same author, same license); no git history migration is needed.
  3. Driver layer: import under DrevOps\BehatSteps\Driver\, PSR-4 throughout, delete the 4 deprecated handlers, add BatchCapabilityInterface, make login/logout throw UnsupportedDriverActionException, fix the leftover Rector PHP set and branch-alias.
  4. Behat layer: import the extension as BehatStepsExtension + managers + thin RawContext, relocate EntityFieldParser into Driver\Field\Parser, absorb the Mink glue with the interop bridge (2.11), delete the dormant and vestigial pieces.
  5. Vocabulary: re-root the 51 traits under Steps\, re-express surviving DrupalExtension steps in the v3 grammar, work through the merge worklist (2.6), unify the entity registry and the bootstrap contract, apply the steps/helpers separation rule.
  6. Tooling and docs: 1 docs generator + STEPS.md + documented helper API, Renovate, coverage gates, the merged fixture site; quick start showing both scenario styles (the '2 styles, 2 jobs' docs page), suite-per-surface layout, behat.php as primary config format.
  7. Release: migration guides (1 per audience) + Rector sets; 4.0.0-alpha1 dogfooded in Vortex and 1 real client project; 4.0.0.
  8. Ecosystem: propose pointer releases and Packagist abandonment for drupal/drupal-driver and drupal/drupal-extension to their owner - a proposal, not our call; port drevops/behat-screenshot and drevops/behat-phpserver to the absorbed Mink glue; announce (blog + Drupal Slack + DrupalExtension issue queue).
  9. Support window: the current v3 minor is LTS until 1 July 2027, receiving bugfixes and security updates only; the old driver/extension lines per their owner's appetite.

5. Open questions

  1. Behat 4 final timing: constrain ^3.32 || ^4.0, CI legs on both, Gherkin 3.2 mode on (2.10). Residual watch items: the Behat 4 final release date against the build schedule, and the absorbed Mink glue's audit list.
  2. Step translations: DrupalExtension ships es/fr xliff catalogues; the v3 grammar has none. Proposal: drop translations at merge, revisit on demand.
  3. Docs: stay with in-repo markdown (docs/ + generated STEPS.md), or stand up a docs site?
  4. The other drevops/behat-* plugins (screenshot, phpserver, format-progress-fail, relativity): keep separate - they're generic Behat plugins usable without this suite - with the Mink-glue ports from phase 8 as the only touch. Proposal: keep separate.
  5. Blackbox-only consumers: worth a documented "no Drupal at all" mode (generic traits + blackbox driver on any site)? It falls out of the architecture for free and is the seed of any future non-Drupal story.

Work is split into the sub-issues attached to this epic; the rollout plan above maps onto them.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions