Contributing code
This page walks through a code change from start to finish: setting up, finding where the change goes, making it, checking it and sending it. CONTRIBUTING.md holds the rules themselves, and each part of this page links the ones it touches.
Before you begin
Make sure you have the following:
- Docker, Pygmy and Ahoy installed.
- No other web server running on your machine, such as Apache, Nginx or MAMP.
- A checkout of the repository in a directory Docker can share, as Docker's file sharing settings describe.
- Node.js 24.22.0 or newer, to build and test the documentation site, which runs on your machine rather than in Docker. A change to a step doesn't need it:
ahoy update-docsruns in Docker.
Set up
From the repository root, start Pygmy and build the fixture site:
pygmy up
ahoy build
ahoy build installs the dependencies and builds a fixture Drupal site in build/, which the unit, kernel and Behat suites all run against. The site's address is http://<directory>.docker.amazee.io/, where <directory> is the name of your checkout directory. ahoy info prints the exact address, and ahoy --help lists every command. Local environment setup has the details.
Find where the change goes
Each kind of change has its own place:
- A step that works on any site goes in a trait under
src/Steps/Web, and a step that only means something on a Drupal site in a trait undersrc/Steps/Drupal. - Logic that several traits share goes in a helper trait under
src/Steps/Helper/Web, or undersrc/Steps/Helper/Drupalwhen it only means something on a Drupal site. - Anything a step needs from the site goes on a capability, an interface a backend in
src/Backendimplements, such asCacheCapabilityInterface, never in the step itself. A step reaches it through$this->backendFor(CacheCapabilityInterface::class), as What a trait needs from the backend explains. A Drupal step trait reaches Drupal through capabilities and nothing else, andahoy lintfails one that calls Drupal directly. - How the library plugs into Behat and Mink lives in the extension layer,
src/Extension.
The Architecture pages explain each layer, and Layers lists what each one may reach. Anatomy of a trait shows the shape every trait follows.
Add or change a step
A step is a public method of a trait, with a #[Given], #[When] or #[Then] attribute holding its text. A new trait is composed by the context for its side, so add it to src/WebContext.php for a web trait or src/DrupalContext.php for a Drupal trait: an import at the top of the file and a use statement inside the class. The trait's docblock also needs a @title tag, which names its page.
To add a step:
- Word the step by the steps format: who acts,
Givenfor a state,Whenfor an action,Thenwithshould, and where each value goes.ahoy lint-docschecks the rules that section marks "(lint)". - Name its method by the method naming conventions.
- Keep its body thin: it parses its arguments, calls helpers and guards on what they return. When it fails, it throws 1 of the exception types, with a message worded by Failure messages.
- Give it a docblock: a summary, an example between
@codeand@endcode, and, after the example, an@actor:personor@actor:systemline. It'spersonwhen a person does or sees the step through the browser, a terminal or an HTTP client, andsystemotherwise. The trait's page andSTEPS.mdare generated from the docblock, and Anatomy of a trait shows a complete one. - Add a scenario that runs the step to the trait's feature file in
tests/behat/features, named after the trait:cookie.featureforCookieTrait, anddrupal_big_pipe.featureforDrupal\BigPipeTrait. Give the scenario a descriptive tag, such as@datetime, so it can run on its own withahoy test-bdd -- --tags=datetime, and tag it@javascripttoo when it needs a real browser. The pages and site configuration a scenario runs against are fixtures, which Static fixtures and Updating fixture site cover.StepScenarioCoverageTestin the unit suite fails any step that no scenario runs, as Behat tests explains. - To check how the step fails, add a negative scenario tagged
@test-trait:and the trait's name, such as@test-trait:CookieTraitor@test-trait:Drupal\CacheTrait. It runs the step in a nested Behat run and checks its failure message, as Behat harness conventions describes. - Run
ahoy update-docs. The trait's page underdocs/content/web-steps/ordocs/content/drupal-steps/,STEPS.mdandHELPERS.mdchange to match the code, and a new trait gets a page of its own. - Commit the regenerated files with your change.
ahoy lint-docsfails a branch whose generated pages are out of date.
A step's text is public API, so renaming a step is a breaking change that waits for the next major release, as Public API and versioning describes.
Add or change a helper
A public method of a trait that isn't a step, a hook or a transform is a helper, which projects call from their own steps. It needs a docblock summary and a name with a verb: ahoy update-docs and ahoy lint-docs both stop on a public helper with no summary, unless its docblock marks it @internal. A method that only the trait's own machinery needs stays protected. The helper API has the rules, and ahoy update-docs publishes the change in HELPERS.md, where reviewers see it.
Helpers are public API too, so renaming or removing one is a breaking change that waits for the next major release.
Run the tests
Run the suites from the repository root:
ahoy test-unit # The unit suite
ahoy test-kernel # The kernel suite
ahoy test-coverage # Unit and kernel, with coverage
ahoy test-bdd # Every Behat scenario
ahoy test-bdd tests/behat/features/cookie.feature # The scenarios of 1 feature file
ahoy test-bdd -- --tags=wip # The scenarios tagged @wip
ahoy test-bdd-coverage tests/behat/features/cookie.feature # 1 feature file, with coverage
ahoy cli "php scripts/check-coverage.php CookieTrait" # A trait's coverage and missed lines
A test sits where its subject sits: src/Steps/Helper/Web/StringTrait.php is tested by tests/phpunit/src/Unit/Steps/Helper/Web/StringTraitTest.php. Codecov's codecov/patch and codecov/project checks are required, so a code path no test reaches needs a test rather than a coverage marker. CI collects coverage on 2 legs, both on Behat 3: 1 that runs the browser steps through Selenium and 1 through headless Chrome. Locally, scripts/check-coverage.php reads the coverage of the last ahoy test-bdd-coverage run, nested @test-trait runs included. Running tests and Coverage markers cover the details.
Run the linters
Run the linters from the repository root:
ahoy lint # The code checks
ahoy lint-fix # Fix what the linters can fix by themselves
ahoy lint-docs # The step rules and the generated pages
ahoy lint doesn't run ahoy lint-docs, and CI's Lint job runs both, so run both before you push. Lint lists what ahoy lint runs, and Settled style questions answers the style questions a reviewer would otherwise ask.
Check the architecture docs
A change to a layer, a backend, a capability, the extension's wiring or the scenario lifecycle changes the architecture pages and their diagrams. The update-architecture-docs skill redraws them from the code, so ask an AI agent to "update architecture docs" before you open the pull request. Most changes aren't structural, and then the skill leaves everything as it is. Without an agent, edit the pages by hand and redraw a diagram in draw.io, as Diagram sources describes.
Open a pull request
Send it against main, which holds the 4.x line, or against 3.x for a fix to the 3.x LTS release. CI runs 3 jobs:
- The test matrix runs the unit, kernel and Behat suites across PHP, Drupal and Behat versions.
- Lint checks the code and the generated pages.
- Docs builds and tests this site.
Codecov then reports codecov/patch and codecov/project from the 2 coverage legs. Continuous integration explains each leg. A change to these docs also follows Documentation.
Confirm that every check on the pull request passes, codecov/patch and codecov/project included.