diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d7832a9a..7dfb27c01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,7 +241,7 @@ A trait's directory is its classification, so nothing has to be declared twice: A step is only as portable as the backend behind it, so each trait falls into one of four bands. Which band a trait is in decides which capability its steps resolve, and therefore which suites can run them. - **Nothing.** Every trait under `src/Steps/Web` except `MessageTrait`, `RegionTrait`, `MappingTrait` and `BasicAuthTrait` reads and drives the page through Mink alone. They run on any backend, against any site, with no Drupal at all. -- **Extension configuration, but no backend.** `MessageTrait`, `RegionTrait` and `MappingTrait` read the `selectors`, `regions` and `mappings` maps that `BehatStepsExtension` injects, and `BasicAuthTrait` reads the authentication manager. They need the extension registered, not a bootstrapped site. +- **Extension configuration, but no backend.** `MessageTrait`, `RegionTrait` and `MappingTrait` read the `selectors`, `regions` and `mappings` maps that `BehatStepsExtension` injects, and `BasicAuthTrait` reads the basic authenticator. They need the extension registered, not a bootstrapped site. - **A narrow capability.** `CacheTrait`'s clear and cron steps, `DrushTrait` and the user and content creation steps resolve one named capability (`CacheCapabilityInterface`, `CronCapabilityInterface`, `DrushCapabilityInterface`, `UserCapabilityInterface`, `ContentCapabilityInterface`, `RoleCapabilityInterface`). They work on any backend implementing it, which for most is the Drush backend as well as the in-process one. - **Drupal's API in this process.** Every other trait under `src/Steps/Drupal` calls into `\Drupal::` directly, which only a backend that bootstraps Drupal in-process can serve. Those steps resolve `CoreCapabilityInterface`. diff --git a/HELPERS.md b/HELPERS.md index d34dd11e2..a9a873998 100644 --- a/HELPERS.md +++ b/HELPERS.md @@ -29,7 +29,7 @@ | [WaitTrait](#waittrait) | 1 | Wait for a period of time or for AJAX to finish. | | [XmlTrait](#xmltrait) | 6 | Assert XML responses with element and attribute checks. | | [RequestHeadersTrait](#requestheaderstrait) | 1 | Holds the request headers shared by the traits that issue HTTP requests. | -| [TableTransposeTrait](#tabletransposetrait) | 2 | Reads a vertical Gherkin table as one set of values per entity. | +| [TableTransposeTrait](#tabletransposetrait) | 2 | Reads a vertical Gherkin table as 1 set of values per entity. | ### Index of Drupal helpers @@ -48,7 +48,7 @@ | [Drupal\EntityTrait](#drupalentitytrait) | 6 | Create entities of a type that has no dedicated trait. | | [Drupal\FileTrait](#drupalfiletrait) | 3 | Manage Drupal file entities with upload and storage operations. | | [Drupal\MediaTrait](#drupalmediatrait) | 4 | Manage Drupal media entities with type-specific field handling. | -| [Drupal\MenuTrait](#drupalmenutrait) | 2 | Manage Drupal menu systems and menu link rendering. | +| [Drupal\MenuTrait](#drupalmenutrait) | 2 | Manage Drupal menus and menu links. | | [Drupal\ModuleTrait](#drupalmoduletrait) | 4 | Enable and disable Drupal modules with automatic state restoration. | | [Drupal\ParagraphsTrait](#drupalparagraphstrait) | 2 | Manage Drupal paragraphs entities with structured field data. | | [Drupal\QueueTrait](#drupalqueuetrait) | 2 | Manage and assert Drupal queue state. | @@ -180,7 +180,7 @@ Return the JavaScript source to inject into the page public function accessibilityGetPrintCli(): bool
-Return TRUE to print a one-line per-page summary to the console +Return TRUE to print a 1-line per-page summary to the console

@@ -918,7 +918,7 @@ Generates an integer in '[min, max]' inclusive public function randomGenerateMachineName(int $length): string
-Generates a Drupal-shaped machine name (lowercase + underscores) +Generates a lowercase alphanumeric machine name starting with a letter

@@ -1188,7 +1188,7 @@ Return the configured AJAX timeout, in seconds public function xmlParse(string $content): array
-Parse XML content without disturbing the cached document +Parse XML content without altering the cached document

@@ -1261,7 +1261,7 @@ $this->requestHeadersSet('X-Acme-Token', 'secret'); [Source](src/Helper/Web/TableTransposeTrait.php) -> Reads a vertical Gherkin table as one set of values per entity. +> Reads a vertical Gherkin table as 1 set of values per entity.
public function tableTransposeHorizontal(array $entities): TableNode @@ -1375,7 +1375,7 @@ Read a stored configuration value, ignoring runtime overrides > Manage Drupal content blocks.
- public function contentBlockCreateSingle(string $type, array $values): BlockContent + public function contentBlockCreateSingle(string $content_block_type, array $values): BlockContent
Create a block content entity with the specified type and field values @@ -1384,7 +1384,7 @@ Create a block content entity with the specified type and field values
- public function contentBlockLoadMultiple(string $type, array $conditions = []): array + public function contentBlockLoadMultiple(string $content_block_type, array $conditions = []): array
Load multiple content blocks with specified type and conditions @@ -1669,7 +1669,7 @@ Visit the action page of the media with a specified name [Source](src/Steps/Drupal/MenuTrait.php), [Steps](STEPS.md#drupalmenutrait) -> Manage Drupal menu systems and menu link rendering. +> Manage Drupal menus and menu links.
public function menuFindByLabel(string $label): ?MenuInterface @@ -2251,7 +2251,7 @@ Returns the basic authenticator
- public function getBrowserResolver(): BrowserCapabilityResolver + public function getBrowserCapabilityResolver(): BrowserCapabilityResolver
Returns the browser capability resolver, creating it on first use diff --git a/MIGRATION.md b/MIGRATION.md index 0cc08a3a9..4147d9451 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -407,8 +407,8 @@ A renamed placeholder renames the method parameter behind it, because Behat bind | Method | Before | After | | --- | --- | --- | -| `ElementTrait::elementFollowLinkByIndex()` | `$text` | `$link` | -| `ElementTrait::elementPressButtonByIndex()` | `$label` | `$button` | +| `ElementTrait::elementFollowLinkWithIndex()` | `$text` | `$link` | +| `ElementTrait::elementPressButtonWithIndex()` | `$label` | `$button` | `DateTrait` expands `[relative:...]` tokens in `:partial_value` arguments as well as in `:value`, `:datetime` and `:expected_value` ones. The region, row and command output assertions now take `:value`, so a token in their argument is expanded rather than compared as written. @@ -936,11 +936,18 @@ The Drupal Extension's `new` mail family tracked messages sent since the previou | `Given I wait for AJAX to finish` | `When I wait for AJAX to finish` | | `When (I )break` | dropped; use a debugger or `When I print last response` (Mink) | +### Metatag + +| Before | After | +| --- | --- | +| Then the meta robots should include :directive | Then the meta robots should contain :directive | +| Then the meta robots should not include :directive | Then the meta robots should not contain :directive | + Random-value tokens (`[?name:type]`) and mapping tokens (`{{ Key }}`) are unchanged: `Steps\Web\RandomTrait` and `Steps\Web\MappingTrait` carry them, and a context composes the trait instead of registering `RandomContext` or `MappingContext`. ## Unified entity cleanup -Every entity a creation step or the backend creates is registered on `Helper\Drupal\EntityLifecycleTrait` and deleted in reverse creation order by one `entityLifecycleCleanAll` hook. Every trait that creates an entity composes that helper, and trait flattening is idempotent, so however many of them a context carries there is still one registry and one hook. There is no second registry and no exclusion list, so a node, a term and a media item created in one scenario come down in the order that respects the references between them. +Every entity a creation step or the backend creates is registered on `Helper\Drupal\EntityLifecycleTrait` and deleted in reverse creation order by one `entityLifecycleAfterScenario` hook. Every trait that creates an entity composes that helper, and trait flattening is idempotent, so however many of them a context carries there is still one registry and one hook. There is no second registry and no exclusion list, so a node, a term and a media item created in one scenario come down in the order that respects the references between them. An entity a project saves through Drupal's API in its own step joins that teardown only when the step registers it, which it does with `$this->entityLifecycleRegister($entity)`. Without that call the entity survives the scenario. @@ -1023,9 +1030,9 @@ Everything `RawContext` declared about Drupal moved under `DrevOps\BehatSteps\He | Helper | Holds | Composed by | | --- | --- | --- | -| `Helper\Drupal\EntityLifecycleTrait` | `entityLifecycleNodeCreate()`, `entityLifecycleTermCreate()`, `entityLifecycleCreate()`, `entityLifecycleLanguageCreate()`, `entityLifecycleRegister()`, `entityLifecycleParseFields()`, `entityLifecycleCleanAll()`, `entityLifecycleAlterNodeParameters()` | the 13 step traits that create entities, and `UserTrait` through `AuthTrait` | +| `Helper\Drupal\EntityLifecycleTrait` | `entityLifecycleNodeCreate()`, `entityLifecycleTermCreate()`, `entityLifecycleCreate()`, `entityLifecycleLanguageCreate()`, `entityLifecycleRegister()`, `entityLifecycleParseFields()`, `entityLifecycleAfterScenario()`, `entityLifecycleBeforeNodeCreate()` | the 13 step traits that create entities, and `UserTrait` through `AuthTrait` | | `Helper\Drupal\AuthTrait` | `authUserCreate()`, `authLogin()`, `authLogout()`, `authIsLoggedIn()`, `authGetUserRegistry()`, `authSetUserRegistry()`, `authGetAuthenticator()`, `authSetAuthenticator()`, `authCleanUsers()`, `authCleanRoles()` | `Steps\Drupal\UserTrait` | -| `Helper\Drupal\StaticCacheTrait` | `staticCacheClear()` | `Steps\Drupal\CacheTrait` | +| `Helper\Drupal\StaticCacheTrait` | `staticCacheAfterScenario()` | `Steps\Drupal\CacheTrait` | | `Helper\Drupal\FixtureFileTrait` | the 5 `fixtureFile*()` methods | `ContentTrait`, `MediaTrait` | | `Helper\Drupal\QueryTrait` | `queryEntityIds()`, `queryNodeIds()` | 9 step traits | @@ -1048,8 +1055,8 @@ A call or an override in a consumer context is renamed: | `languageCreate()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleLanguageCreate()` | | `entityRegister()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleRegister()` | | `parseEntityFields()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleParseFields()` | -| `cleanEntities()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleCleanAll()` | -| `alterNodeParameters()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleAlterNodeParameters()` | +| `cleanEntities()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleAfterScenario()` | +| `alterNodeParameters()` | `Helper\Drupal\EntityLifecycleTrait::entityLifecycleBeforeNodeCreate()` | | `userCreate()` | `Helper\Drupal\AuthTrait::authUserCreate()` | | `login()` | `Helper\Drupal\AuthTrait::authLogin()` | | `logout()` | `Helper\Drupal\AuthTrait::authLogout()` | @@ -1058,7 +1065,7 @@ A call or an override in a consumer context is renamed: | `setUserManager()` | `Helper\Drupal\AuthTrait::authSetUserRegistry()` | | `cleanUsers()` | `Helper\Drupal\AuthTrait::authCleanUsers()` | | `cleanRoles()` | `Helper\Drupal\AuthTrait::authCleanRoles()` | -| `clearStaticCaches()` | `Helper\Drupal\StaticCacheTrait::staticCacheClear()` | +| `clearStaticCaches()` | `Helper\Drupal\StaticCacheTrait::staticCacheAfterScenario()` | Three of those names were also skip tags. A skip tag names a trait rather than a method, so `@behat-steps-skip:cleanEntities` becomes `@behat-steps-skip:EntityLifecycleTrait`, and `@behat-steps-skip:cleanUsers` and `@behat-steps-skip:cleanRoles` both become `@behat-steps-skip:AuthTrait`. @@ -1225,6 +1232,10 @@ A scenario that asserted a falsy parameter away with `Then the current URL shoul Then the current URL should not have the query parameter "filter" with the value "recent" ``` +## A value of `0` is not empty + +A few checks read a string with `empty()`, which treats the string `0` as absent. They compare against the empty string now, so `0` is a value like any other: `Given the password for the user :name is "0"` sets the password instead of failing with `Password must not be empty.`, an attribute whose value is `0` counts as present for the `the element :selector with the attribute :attribute ...` steps, an iframe named `0` is switched to by name, a WYSIWYG field with the id `0` is filled through its id, and `fileCreateEntity()` honours a destination URI of `0`. A `drush` backend configured with an alias or root path of `0` is likewise read as configured. + ## Unified assertion exceptions Assertion steps used to throw whatever their trait happened to reach for: `ExpectationException` in most places, plain `\Exception` in 8 traits, `\RuntimeException` in `XmlTrait`'s format check, and `\InvalidArgumentException` in 2 select-option steps. The type is part of the contract - consumers catch on it - so it now follows one rule. @@ -1275,7 +1286,7 @@ If your project catches an exception from one of these steps, update the type: | --- | --- | --- | | `the response should be in XML format` | `Failed to load XML. Errors: ...` | `The response is not valid XML: ...` | | `the option :option should exist within the select element :selector` | `Element "..." is not found.` / `Option "..." is not found in select "...".` | `Select with id\|name\|label "..." not found.` / `Option in the select "..." with value\|text "..." not found.` | -| `the option :option should not exist within the select element :selector` | `Element "..." is not found.` / `Option "..." is found in select "...", but should not.` | `Select with id\|name\|label "..." not found.` / `The option "..." was found in the select "..." on the page ..., but should not exist.` | +| `the option :option should not exist within the select element :selector` | `Element "..." is not found.` / `Option "..." is found in select "...", but should not.` | `Select with id\|name\|label "..." not found.` / `The option "..." was found in the select "..." on the page ..., but it should not exist.` | | `I unselect the option :option from the select :selector` | `The option "..." was not found in the select "...".` | `Option in the select "..." with value\|text "..." not found.` | | `the option :option should not be selected within the select element :selector` | `The option "..." was not found in the select "..." on the page ....` | `Option in the select "..." with value\|text "..." not found.` | | `I fill in the multi-value field :field with the following values:` | `Could not locate input row N for multi-value field "...".` | `Input row of the multi-value field "..." with index "N" not found.` | @@ -1303,6 +1314,41 @@ The same rule now covers the backend layer and the Behat services under `src/Beh Behat reports every one of these as a failed step either way, so a scenario that simply runs to a failure behaves the same. Only code that catches a specific type, or asserts on the message text, needs changing. +## Failure messages read one way + +A failure message quotes the values it names in double quotes, ends with a period, and reports something present that must be absent with `, but it should not`. The messages below changed wording only, so the exception a step throws is the same as the row above says; only a test asserting on the text needs the new one. Rows were checked against 3.14.4: a message introduced in 4.x is not listed. + +| Trait | Before | After | +| --- | --- | --- | +| Drupal\BlockTrait | The block "..." exists but should not. | The block "..." exists, but it should not. | +| Drupal\BlockTrait | Block "..." is in region "..." but should not be. | Block "..." is in region "...", but it should not be. | +| Drupal\ConfigTrait | The config "..." key "..." has the ... "...", which contains "..." but should not. | The config "..." key "..." has the ... "...", which contains "...", but it should not. | +| Drupal\FileTrait | File contents "..." contains "...", but should not. | File contents "..." contains "...", but it should not. | +| LinkTrait | The link href "..." matches the specified href "..." but should not. | The link href "..." matches the specified href "...", but it should not. | +| LinkTrait | The link with the title "..." exists, but should not. | The link with the title "..." exists, but it should not. | +| ElementTrait | Element defined by "..." selector is visible on the page, but should not be. | Element defined by "..." selector is visible on the page, but it should not be. | +| ElementTrait | Element(s) defined by "..." selector is displayed within a viewport with a top offset of N pixels, but should not be. | Element(s) defined by "..." selector is displayed within a viewport with a top offset of N pixels, but it should not be. | +| ElementTrait | Element(s) defined by "..." selector is displayed within a viewport, but should not be. | Element(s) defined by "..." selector is displayed within a viewport, but it should not be. | +| FieldTrait | The field "..." is empty, but should not be. | The field "..." is empty, but it should not be. | +| FieldTrait | The field "..." is marked as required, but should not be. | The field "..." is marked as required, but it should not be. | +| FieldTrait | The option "..." was selected in the select "..." on the page ..., but should not be. | The option "..." was selected in the select "..." on the page ..., but it should not be. | +| FieldTrait | The radio button "..." is selected, but should not be. | The radio button "..." is selected, but it should not be. | +| FileDownloadTrait | Found file partially named "..." in archive but should not. | Found file partially named "..." in archive, but it should not. | +| ResponseTrait | The response contains the header "...", but should not. | The response contains the header "...", but it should not. | +| PathTrait | The parameter "..." is in the URL but should not be. | The parameter "..." is in the URL, but it should not be. | +| PathTrait | The parameter "..." with value "..." is in the URL but should not be. | The parameter "..." with value "..." is in the URL, but it should not be. | +| MetatagTrait | The robots meta tag does not include the "..." directive. Found: .... | The robots meta tag does not contain the "..." directive. Found: .... | +| MetatagTrait | The robots meta tag includes the "..." directive, but it should not. | The robots meta tag contains the "..." directive, but it should not. | +| XmlTrait | Failed to serialise the response for DTD validation. | Failed to serialize the response for DTD validation. | +| JsonTrait | The JSON response must decode to an array or object, but got integer. (also `boolean`, `double`, `NULL`) | The JSON response must decode to an array or object, but got int. (also `bool`, `float`, `null`) | +| Drupal\ContentTrait | Content type "..." does not exist. | The content type "..." does not exist. | +| Drupal\ContentBlockTrait | Content block type "..." does not exist. | The content block type "..." does not exist. | +| Drupal\UserTrait | User with name "..." does not exist. | The user "..." does not exist. | +| Drupal\MediaTrait | Cannot create media because provided bundle '...' does not exist. | Cannot create media because provided bundle "..." does not exist. | +| ResponsiveTrait | Breakpoint '...' not found. Available breakpoints: ... | Breakpoint "..." not found. Available breakpoints: .... | +| ResponsiveTrait | Invalid breakpoint format for '...': '...'. Expected format: WIDTHxHEIGHT (e.g., 1920x1080) | Invalid breakpoint format for "...": "...". Expected format: WIDTHxHEIGHT (e.g., 1920x1080). | +| ResponsiveTrait | Invalid breakpoint format: '...'. Expected format: WIDTHxHEIGHT (e.g., 1920x1080) | Invalid breakpoint format: "...". Expected format: WIDTHxHEIGHT (e.g., 1920x1080). | + ## Tightened public surface A handful of trait members exposed more than the surrounding code intended. Each one is reachable from a consuming context, so they're grouped here as breaking changes rather than fixed quietly. A `PublicSurfaceTest` now holds each of these conventions, so the surface stays deliberate from here on. @@ -1363,12 +1409,12 @@ Hook methods used to come in 3 shapes: taking and using the scope, taking and ig | Hook | New signature | | --- | --- | -| `AccessibilityTrait::accessibilityAggregateRender()` | `(AfterSuiteScope $scope)` | +| `AccessibilityTrait::accessibilityAfterSuite()` | `(AfterSuiteScope $scope)` | | `AccessibilityTrait::accessibilityAggregateReset()` | `(BeforeSuiteScope $scope)` | | `AccessibilityTrait::accessibilityCaptureBaseDir()` | `(BeforeSuiteScope $scope)` | | `CommandTrait::commandAfterScenario()` | `(AfterScenarioScope $scope)` | | `CommandTrait::commandBeforeScenario()` | `(BeforeScenarioScope $scope)` | -| `Drupal\BigPipeTrait::bigPipeWaitBeforeStep()` | `(BeforeStepScope $scope)` | +| `Drupal\BigPipeTrait::bigPipeBeforeStep()` | `(BeforeStepScope $scope)` | | `JsonTrait::jsonAfterScenario()` | `(AfterScenarioScope $scope)` | | `JsonTrait::jsonBeforeScenario()` | `(BeforeScenarioScope $scope)` | | `XmlTrait::xmlAfterScenario()` | `(AfterScenarioScope $scope)` | @@ -1561,6 +1607,21 @@ The subject is what the step asserts about. `ElementTrait`'s attribute steps ass It still asserts that an email went to the address and that no collected email's body contains the text. +### A qualifier on an action is `With` + +`By` is the lookup spelling: `Find`, `Get` and `Exists` methods name the key they search by, as `blockFindByLabel()` and `userExistsByMail()` do. An assertion or an action that narrows its target reads `With`, as its step text does, so the 8 names below join `mediaAssertExistsWithName()` and `contentVisitEditPageWithTitle()`. Step text is unchanged. + +| Trait | Old | New | +| --- | --- | --- | +| `Drupal\UserTrait` | `userAssertExistsByMail()` | `userAssertExistsWithMail()` | +| `Drupal\UserTrait` | `userAssertNotExistsByMail()` | `userAssertNotExistsWithMail()` | +| `Drupal\TaxonomyTrait` | `taxonomyAssertTermExistsByName()` | `taxonomyAssertTermExistsWithName()` | +| `Drupal\TaxonomyTrait` | `taxonomyAssertTermNotExistsByName()` | `taxonomyAssertTermNotExistsWithName()` | +| `Drupal\ContentTrait` | `contentRebuildAccessGrantsByTitle()` | `contentRebuildAccessGrantsWithTitle()` | +| `ElementTrait` | `elementClickByIndex()` | `elementClickWithIndex()` | +| `ElementTrait` | `elementFollowLinkByIndex()` | `elementFollowLinkWithIndex()` | +| `ElementTrait` | `elementPressButtonByIndex()` | `elementPressButtonWithIndex()` | + ### `Has` names something the subject holds `Has` named something a subject holds, such as a user's roles, and also stood in for a comparison: `stateAssertHasValue()` checks that a state value equals the expected one. A compared value now reads `Equals` or `Contains`, and `Has` stays for what a subject holds, as in `userAssertHasRoles()` and `elementAssertHasKeyboardFocus()`. @@ -1587,7 +1648,7 @@ It still asserts that an email went to the address and that no collected email's | `MetatagTrait` | `metatagAssertRobotsNotIncludes()` | `metatagAssertRobotsNotContains()` | | `MetatagTrait` | `metatagAssertMetaSetPresent()` | `metatagAssertMetaSetExists()` | -The step text still reads `the meta robots should include :directive`. +The step text follows the method: `the meta robots should include :directive` is `the meta robots should contain :directive`, and `should not include` is `should not contain`. ### Only an assertion is named `Assert` @@ -1604,6 +1665,29 @@ A method that fails with an assertion exception is named as an assertion, whethe | `CookieTrait` | `cookieExists()` | `cookieAssertExists()` | | `CookieTrait` | `cookieNotExists()` | `cookieAssertNotExists()` | +### A hook is named for its event + +A hook method reads ``, so `configBeforeScenario()` and `contentBeforeNodeCreate()` already told the reader when they run. The hooks that were named for what they do take the same shape. A skip tag names a trait, not a hook, so no tag changes. + +| Trait | Old | New | +| --- | --- | --- | +| Drupal\TimeTrait | timeCleanup() | timeAfterScenario() | +| Drupal\WatchdogTrait | watchdogSetScenario() | watchdogBeforeScenario() | +| Drupal\BigPipeTrait | bigPipeWaitBeforeStep() | bigPipeBeforeStep() | +| AccessibilityTrait | accessibilitySetupScenario() | accessibilityBeforeScenario() | +| AccessibilityTrait | accessibilityAutoAssess() | accessibilityAfterStep() | +| AccessibilityTrait | accessibilityFinalizeScenario() | accessibilityAfterScenario() | +| AccessibilityTrait | accessibilityAggregateRender() | accessibilityAfterSuite() | + +### A bundle parameter is named after its entity type + +A helper that takes a bundle names the parameter after the entity type, as the step placeholders do. 2 `Drupal\ContentBlockTrait` helpers took `$type`; a call that passes the argument by name renames it. + +| Method | Before | After | +| --- | --- | --- | +| `contentBlockCreateSingle()` | `string $type, array $values` | `string $content_block_type, array $values` | +| `contentBlockLoadMultiple()` | `string $type, array $conditions = []` | `string $content_block_type, array $conditions = []` | + ## A class is named for the role it plays 5 classes under `Behat\Manager` shared a `Manager` suffix while playing 3 different roles, so nothing in a name told a lookup table apart from a service that acts. The suffix is replaced by a 2-part rule: a `*Registry` holds things and looks them up, and anything that performs an action takes an agent noun. @@ -1722,7 +1806,7 @@ $this->browserDriverFor(JavascriptCapabilityInterface::class); ### Registering an adapter for another browser driver ```php -$this->getBrowserResolver()->registerAdapter(AcmeDriverAdapter::class); +$this->getBrowserCapabilityResolver()->registerAdapter(AcmeDriverAdapter::class); ``` An adapter extends `BrowserAdapterBase`, implements the capability interfaces its browser driver can honour, and answers `supports()` for the browser driver it speaks for. A registered adapter is offered each browser driver ahead of the shipped ones. diff --git a/README.md b/README.md index 671b7aba7..a44d75b4a 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ See [MIGRATION.md](MIGRATION.md) for migration guides. | [IframeTrait](STEPS.md#iframetrait) | Switch between iframes and the root document. | | [JavascriptTrait](STEPS.md#javascripttrait) | Automatically detect JavaScript errors during test execution. | | [JsonTrait](STEPS.md#jsontrait) | Assert JSON responses with path and schema checks. | -| [KeyboardTrait](STEPS.md#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. | +| [KeyboardTrait](STEPS.md#keyboardtrait) | Simulate keyboard interactions in the browser. | | [LinkTrait](STEPS.md#linktrait) | Verify link elements with attribute and content assertions. | | [MappingTrait](STEPS.md#mappingtrait) | Replace `{{ Key }}` tokens in step arguments and table cells. | | [MessageTrait](STEPS.md#messagetrait) | Assert status, error, warning and success messages rendered on the page. | @@ -80,7 +80,7 @@ See [MIGRATION.md](MIGRATION.md) for migration guides. | [PathTrait](STEPS.md#pathtrait) | Navigate and verify paths with URL validation. | | [RandomTrait](STEPS.md#randomtrait) | Replace random-value tokens in step arguments and table cells. | | [RegionTrait](STEPS.md#regiontrait) | Interact with and assert against named page regions. | -| [ResponseTrait](STEPS.md#responsetrait) | Verify HTTP responses with status code and header checks. | +| [ResponseTrait](STEPS.md#responsetrait) | Verify HTTP response headers. | | [ResponsiveTrait](STEPS.md#responsivetrait) | Test responsive layouts with viewport control. | | [RestTrait](STEPS.md#resttrait) | Lightweight REST API testing with no Drupal dependencies. | | [TableTrait](STEPS.md#tabletrait) | Interact with HTML table elements and assert their content. | @@ -107,12 +107,12 @@ See [MIGRATION.md](MIGRATION.md) for migration guides. | [Drupal\FileTrait](STEPS.md#drupalfiletrait) | Manage Drupal file entities with upload and storage operations. | | [Drupal\LanguageTrait](STEPS.md#drupallanguagetrait) | Create the languages a scenario needs. | | [Drupal\MediaTrait](STEPS.md#drupalmediatrait) | Manage Drupal media entities with type-specific field handling. | -| [Drupal\MenuTrait](STEPS.md#drupalmenutrait) | Manage Drupal menu systems and menu link rendering. | +| [Drupal\MenuTrait](STEPS.md#drupalmenutrait) | Manage Drupal menus and menu links. | | [Drupal\ModuleTrait](STEPS.md#drupalmoduletrait) | Enable and disable Drupal modules with automatic state restoration. | | [Drupal\ParagraphsTrait](STEPS.md#drupalparagraphstrait) | Manage Drupal paragraphs entities with structured field data. | | [Drupal\QueueTrait](STEPS.md#drupalqueuetrait) | Manage and assert Drupal queue state. | | [Drupal\RedirectTrait](STEPS.md#drupalredirecttrait) | Manage Drupal redirect entities provided by the contrib `redirect` module. | -| [Drupal\SearchApiTrait](STEPS.md#drupalsearchapitrait) | Assert Drupal Search API with index and query operations. | +| [Drupal\SearchApiTrait](STEPS.md#drupalsearchapitrait) | Run Drupal Search API indexing and cron hooks. | | [Drupal\StateTrait](STEPS.md#drupalstatetrait) | Manage and assert Drupal State API values with automatic revert. | | [Drupal\TaxonomyTrait](STEPS.md#drupaltaxonomytrait) | Manage Drupal taxonomy terms with vocabulary organization. | | [Drupal\TestmodeTrait](STEPS.md#drupaltestmodetrait) | Configure Drupal Testmode module for controlled testing scenarios. | diff --git a/STEPS.md b/STEPS.md index 06f55fab9..ccbc130ca 100644 --- a/STEPS.md +++ b/STEPS.md @@ -17,7 +17,7 @@ | [IframeTrait](#iframetrait) | Switch between iframes and the root document. | | [JavascriptTrait](#javascripttrait) | Automatically detect JavaScript errors during test execution. | | [JsonTrait](#jsontrait) | Assert JSON responses with path and schema checks. | -| [KeyboardTrait](#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. | +| [KeyboardTrait](#keyboardtrait) | Simulate keyboard interactions in the browser. | | [LinkTrait](#linktrait) | Verify link elements with attribute and content assertions. | | [MappingTrait](#mappingtrait) | Replace `{{ Key }}` tokens in step arguments and table cells. | | [MessageTrait](#messagetrait) | Assert status, error, warning and success messages rendered on the page. | @@ -26,7 +26,7 @@ | [PathTrait](#pathtrait) | Navigate and verify paths with URL validation. | | [RandomTrait](#randomtrait) | Replace random-value tokens in step arguments and table cells. | | [RegionTrait](#regiontrait) | Interact with and assert against named page regions. | -| [ResponseTrait](#responsetrait) | Verify HTTP responses with status code and header checks. | +| [ResponseTrait](#responsetrait) | Verify HTTP response headers. | | [ResponsiveTrait](#responsivetrait) | Test responsive layouts with viewport control. | | [RestTrait](#resttrait) | Lightweight REST API testing with no Drupal dependencies. | | [TableTrait](#tabletrait) | Interact with HTML table elements and assert their content. | @@ -53,12 +53,12 @@ | [Drupal\FileTrait](#drupalfiletrait) | Manage Drupal file entities with upload and storage operations. | | [Drupal\LanguageTrait](#drupallanguagetrait) | Create the languages a scenario needs. | | [Drupal\MediaTrait](#drupalmediatrait) | Manage Drupal media entities with type-specific field handling. | -| [Drupal\MenuTrait](#drupalmenutrait) | Manage Drupal menu systems and menu link rendering. | +| [Drupal\MenuTrait](#drupalmenutrait) | Manage Drupal menus and menu links. | | [Drupal\ModuleTrait](#drupalmoduletrait) | Enable and disable Drupal modules with automatic state restoration. | | [Drupal\ParagraphsTrait](#drupalparagraphstrait) | Manage Drupal paragraphs entities with structured field data. | | [Drupal\QueueTrait](#drupalqueuetrait) | Manage and assert Drupal queue state. | | [Drupal\RedirectTrait](#drupalredirecttrait) | Manage Drupal redirect entities provided by the contrib `redirect` module. | -| [Drupal\SearchApiTrait](#drupalsearchapitrait) | Assert Drupal Search API with index and query operations. | +| [Drupal\SearchApiTrait](#drupalsearchapitrait) | Run Drupal Search API indexing and cron hooks. | | [Drupal\StateTrait](#drupalstatetrait) | Manage and assert Drupal State API values with automatic revert. | | [Drupal\TaxonomyTrait](#drupaltaxonomytrait) | Manage Drupal taxonomy terms with vocabulary organization. | | [Drupal\TestmodeTrait](#drupaltestmodetrait) | Configure Drupal Testmode module for controlled testing scenarios. | @@ -88,19 +88,21 @@ > - `@behat-steps-skip:AccessibilityTrait` Opt the scenario or feature out entirely. > > Tool-agnostic. Any engine that runs inside the existing Mink session can -> be plugged in by overriding `accessibilityRunEngine()` (perform the -> assessment, return raw results) and `accessibilityNormalizeResults()` -> (remap raw output into the canonical shape the rest of the trait expects). +> be plugged in by overriding `accessibilityRunEngine()` and +> `accessibilityNormalizeResults()`. The first performs the assessment and +> returns raw results; the second remaps raw output into the canonical shape +> the rest of the trait reads. >

> Reporting. Each scenario writes its own HTML and JUnit report. After the > whole suite, a single cross-page `accessibility_report_.html` > (timestamp `YYYYMMDD_HHMMSS`) is written to the same directory, -> de-duplicating every assessed page and rolling violations up by rule. One -> file is written per run, so a run never overwrites a previous one. The +> de-duplicating every assessed page and rolling violations up by rule. +>

+> 1 file is written per run, so a run never overwrites a previous one. The > aggregate accumulates in process-global state, so under parallel Behat each > process writes its own report. >

-> Console output. A one-line per-page summary can be printed to the console +> Console output. A 1-line per-page summary can be printed to the console > as pages are assessed. Printing is off by default; set the > `BEHAT_ACCESSIBILITY_PRINT` environment variable to a non-empty value other > than `0`, or override `accessibilityGetPrintCli()`, to enable it. @@ -181,7 +183,8 @@ Then the current page should pass accessibility checks for the tags "wcag2a" > > Commands run through the system shell with the privileges of the process > that runs the tests. The command string is passed to the shell verbatim and -> is subject to shell expansion, so never interpolate untrusted input into it. +> is subject to shell expansion, so untrusted input must never be +> interpolated into it. ### Options @@ -346,8 +349,8 @@ Then the command should complete in more than 1 second > Verify and inspect browser cookies. > - Assert cookie existence and values with exact or partial matching. -> - Support both WebDriver and BrowserKit browser drivers for test -> compatibility. +> - Read cookies through whichever browser driver provides the cookie +> capability.
@@ -536,12 +539,14 @@ Then a cookie with a name containing "user" and a value containing "guest" shoul > > Examples: > - `[relative:-1 day]` converted to `1893456000` -> - `[relative:-1 day#Y-m-d]` converted to `2017-11-5` +> - `[relative:-1 day#Y-m-d]` converted to `2017-11-05` > -> `dateRelativeProcessValue()` is public API. It and its helpers are static so -> a token resolves without a context instance. Late static binding routes the -> resolution through a `dateGetNow()` override in the composing context, -> which is the supported seam for pinning the clock. +> `dateRelativeProcessValue()` is public API. It and its helpers are static, +> so a token resolves without a context instance. +>

+> Late static binding routes the resolution through a `dateGetNow()` override +> in the composing context. That override is the supported way to hold the +> current time constant. >

> Skip processing with tag: `@behat-steps-skip:DateTrait`. @@ -558,9 +563,9 @@ Then a cookie with a name containing "user" and a value containing "guest" shoul > Append on-failure diagnostics to the failure message of any failed step. >

-> When a step fails, the exception message alone is often not enough to -> diagnose a red CI run. This trait hooks every step and, only when the step -> failed, appends a compact diagnostics block to the failure message: +> The exception message of a failed step is often not enough to diagnose a +> CI failure. This trait hooks every step and, only when the step failed, +> appends a compact diagnostics block to the failure message: > - `URL` - the current page URL. > - `HTTP status` - the last response status code. > - `Browser driver` - the class of the browser driver behind the session. @@ -608,15 +613,16 @@ Then a cookie with a name containing "user" and a value containing "guest" shoul [Source](src/Steps/Web/DropzoneTrait.php), [Example](tests/behat/features/dropzone.feature) > Simulate a real multi-file drag-and-drop gesture onto a Dropzone target. -> - Drop one or more files on a CSS-selected target in a single native event. +> - Drop 1 or more files on a CSS-selected target in a single native event. > - Fixture paths resolve against the Mink `files_path` parameter. > - Works on any element that handles native `drop` events (Dropzone.js, > custom drop targets, framework widgets). >

> Mink's `attachFile` writes each file to a hidden `` > sequentially, so file A finishes uploading before file B starts. Real users -> release multiple files together, which fires a single `drop` event whose -> `dataTransfer.files` contains all of them and triggers concurrent uploads. +> release multiple files together, so a single `drop` event carries all of +> them in `dataTransfer.files` and triggers concurrent uploads. +>

> Race conditions in dedup maps, status indicators, error handlers and > server-side queues reproduce only under the multi-file path. >

@@ -641,7 +647,7 @@ When I drop the file "document.pdf" on the dropzone ".dropzone" @When I drop the following files on the dropzone :selector:
-Drop one or more files on the target element in a single native event +Drop 1 or more files on the target element in a single native event

```gherkin @@ -758,7 +764,7 @@ When I press the button "Delete" with the index 2 @When I trigger the JS event :event on the element :selector
-When I trigger the JS event :event on the element :selector +Trigger a JS event on the element defined by the selector

```gherkin @@ -772,7 +778,7 @@ When I trigger the JS event "click" on the element "#submit-button" @When I scroll to the element :selector
-Scroll to an element with ID +Scroll to the element matching a CSS selector

```gherkin @@ -928,7 +934,7 @@ Then the element "#main-content" with the attribute "class" and a value containi @Then the element :selector with the attribute :attribute and the value :value should not exist
-Assert an element with selector and attribute with a value exists +Assert an element with selector and attribute with a value does not exist

```gherkin @@ -1040,7 +1046,7 @@ Then the element "#page-header" should stack below the element "#modal" @Then the element :selector should be at the top of the viewport
-Assert the element :selector should be at the top of the viewport +Assert that the element is at the top of the viewport

```gherkin @@ -1054,7 +1060,7 @@ Then the element "#header" should be at the top of the viewport @Then the element :selector should be centered in the viewport
-Assert the element :selector should be centered in the viewport +Assert that the element is centered in the viewport

```gherkin @@ -1271,7 +1277,7 @@ Then the element "#main-nav" should contain 3 elements matching ".menu-item" > - Assert field existence, state, and selected options. > - Support for specialized widgets like color pickers and rich text editors. > - Disable browser validation for forms with deferred execution. -> - Use @disable-form-validation tag to automatically disable validation for all forms. +> - The @disable-form-validation tag disables validation for all forms. > > Skip processing with tag: `@behat-steps-skip:FieldTrait` @@ -1719,7 +1725,7 @@ Then the radio button "edit-field-choice-option-b" should not be selected > Test file download functionality with content verification. > - Download files through links and URLs with session cookie handling. -> - Verify file names, content, and extracted archives. +> - Verify file names, content, and zip archive entries. > - Set up download directories and handle file cleanup. > > Skip processing with tag: `@behat-steps-skip:FileDownloadTrait`. @@ -1905,10 +1911,12 @@ When I switch to the root document [Source](src/Steps/Web/JavascriptTrait.php), [Example](tests/behat/features/javascript.feature) > Automatically detect JavaScript errors during test execution. -> - Collects JavaScript errors from `window.onerror` and `console.error`. +> - Collects JavaScript errors from `window.onerror`, `unhandledrejection` +> and `console.error`. > - Automatically asserts no errors at end of scenarios with `@javascript` tag. -> - Errors collected only when URL changes (navigation occurs). -> - Use `@js-errors` tag to bypass error checking when errors are expected. +> - Collects errors after every step and re-injects the collector when the +> URL changes. +> - The `@js-errors` tag bypasses error checking when errors are expected. > > Skip processing with tags: `@behat-steps-skip:JavascriptTrait` >

@@ -2238,10 +2246,10 @@ Then the response should match the JSON schema in the file "json_schema.json" [Source](src/Steps/Web/KeyboardTrait.php), [Example](tests/behat/features/keyboard.feature) -> Simulate keyboard interactions in Drupal browser testing. -> - Trigger key press events including special keys and key combinations. -> - Assert keyboard navigation and shortcut functionality. -> - Support for targeted key presses on specific page elements. +> Simulate keyboard interactions in the browser. +> - Trigger key press events, including named special keys. +> - Press a string of characters 1 key at a time. +> - Target a key press at a page element or at the focused element.
@@ -2307,8 +2315,8 @@ When I press the keys "abc" on the element "#edit-title" [Source](src/Steps/Web/LinkTrait.php), [Example](tests/behat/features/link.feature) > Verify link elements with attribute and content assertions. -> - Find links by title, URL, text content, and class attributes. -> - Test link existence, visibility, and destination accuracy. +> - Find links by title, or by text and href, optionally within an element. +> - Assert link existence and href match. > - Assert absolute and relative link paths. @@ -2455,7 +2463,7 @@ Then the link "Return to site content" should not be an absolute link > declared in does not take part in the lookup. >

> The transform matches the token's braces rather than a placeholder name, so -> one map covers every string argument without the step opting in. +> 1 map covers every string argument without the step opting in. >

> Operates on Gherkin text alone: no Mink session and no backend, so the trait > works in any suite. @@ -2468,7 +2476,7 @@ Then the link "Return to site content" should not be an absolute link | Option | Type | Default | Tag | Description | | --- | --- | --- | --- | --- | | `mapping.enabled` | boolean | `TRUE` | `@behat-steps-skip:MappingTrait` | Replace `{{ Key }}` tokens in step arguments and table cells. Turn it off to pass a token through to a step untouched. | -| `mapping.groups` | map | `[]` | - | Named value mappings grouped for organisation. Group names take no part in the lookup, so a key must be unique across all groups. | +| `mapping.groups` | map | `[]` | - | Named value mappings grouped for organization. Group names take no part in the lookup, so a key must be unique across all groups. | ## MessageTrait @@ -2478,10 +2486,10 @@ Then the link "Return to site content" should not be an absolute link > - Match a single message by substring, per message type. > - Match a table of messages in one step. > -> Each message type resolves to a CSS selector configured under the -> `selectors: messages:` map in the extension configuration, keyed `default`, -> `error`, `success` and `warning`. A message matches when the text of any -> element found by that selector contains the expected string. +> Each message type resolves to a CSS selector from the `message.selectors` +> option, keyed `default`, `error`, `success` and `warning`. A message +> matches when the text of any element found by that selector contains the +> expected string. ### Options @@ -2824,30 +2832,30 @@ Then the page should not be indexable
- @Then the meta robots should include :directive + @Then the meta robots should contain :directive
-Assert the robots meta tag includes a directive +Assert the robots meta tag contains a directive

```gherkin -Then the meta robots should include "noindex" -Then the meta robots should include "nofollow" +Then the meta robots should contain "noindex" +Then the meta robots should contain "nofollow" ```
- @Then the meta robots should not include :directive + @Then the meta robots should not contain :directive
-Assert the robots meta tag does not include a directive +Assert the robots meta tag does not contain a directive

```gherkin -Then the meta robots should not include "noindex" -Then the meta robots should not include "nofollow" +Then the meta robots should not contain "noindex" +Then the meta robots should not contain "nofollow" ``` @@ -3212,11 +3220,11 @@ Then the current URL should not have the query parameter "filter" with the value > Replace random-value tokens in step arguments and table cells. > - Resolve `[?:[,]]` tokens to generated values. -> - Return one value per token for the whole scenario. +> - Return 1 value per token for the whole scenario. > > Built-in types are `string`, `name`, `machine_name`, `int`, `email` and > `uuid`. The default is `string` with length `10`, so `[?title]`, -> `[?title:string]` and `[?title:string,10]` share one value. +> `[?title:string]` and `[?title:string,10]` share 1 value. >

> Operates on Gherkin text alone: no Mink session and no backend, so the trait > works in any suite. @@ -3527,7 +3535,7 @@ Then the element "span" with the text "New" in the region "content" should have [Source](src/Steps/Web/ResponseTrait.php), [Example](tests/behat/features/response.feature) -> Verify HTTP responses with status code and header checks. +> Verify HTTP response headers. > - Assert HTTP header presence and values. @@ -3810,7 +3818,7 @@ Then the REST response should contain "success" > Interact with HTML table elements and assert their content. > - Assert table row and column counts. -> - Assert table column headers in thead. +> - Assert table column headers. > - Assert table empty and non-empty states. > - Assert table sort order by column. > - Assert text values present in a specific table row. @@ -3857,7 +3865,7 @@ When I press the button "Remove" in the row "Article title" @Then the table :selector should have :count row(s)
-Assert that a table has the expected number of rows in its tbody +Assert that a table has the expected number of body rows

```gherkin @@ -3902,7 +3910,7 @@ Then the table ".mytable" should contain the following columns: @Then the table :selector should be empty
-Assert that a table is empty (has no rows in tbody) +Assert that a table is empty (has no body rows)

```gherkin @@ -3916,7 +3924,7 @@ Then the table ".mytable" should be empty @Then the table :selector should not be empty
-Assert that a table is not empty (has rows in tbody) +Assert that a table is not empty (has body rows)

```gherkin @@ -4039,9 +4047,9 @@ Then the link "Delete" should not exist in the row "Article title" > - Wait for jQuery and Drupal AJAX activity to settle, on demand or around > every step that navigates or submits. >

-> Mink's own AJAX wait watches `jQuery.active` alone, while Drupal renders many -> updates through `Drupal.ajax`. An assertion following a click can read the -> page before the update applies, so the wait here watches both. +> A wait on `jQuery.active` alone misses the updates Drupal renders through +> `Drupal.ajax`, so an assertion following a click can read the page before +> the update applies. The wait here watches both. >

> Skip the automatic waits with tag: `@behat-steps-skip:WaitTrait`. @@ -4545,7 +4553,7 @@ Then the response should be a valid Atom feed [Source](src/Steps/Drupal/BatchTrait.php), [Example](tests/behat/features/drupal_batch.feature) > Wait for Drupal's Batch API to finish. -> - Poll the batch progress element until it leaves the page. +> - Poll the batch progress element until the page no longer contains it. > > A batch page reloads itself until the operation completes, so a following > assertion would otherwise read the progress screen rather than the result. @@ -4577,18 +4585,19 @@ When I wait for the batch job to finish > replacements complete fails intermittently with "element not found". >

> With this trait included, every `@javascript` scenario waits before each -> step until no BigPipe placeholder marker remains in the DOM, which removes -> that race without an explicit step. +> step until no BigPipe placeholder marker remains in the DOM. The wait +> removes the race without an explicit step. >

> The wait is best-effort: on timeout the step still runs, so a placeholder > that is never replaced fails the following assertion rather than the wait. >

> A browser driver that runs no JavaScript never replaces those placeholders, > and does not follow the `http-equiv=refresh` fallback either. An -> authenticated-user assertion on such a browser driver silently misses -> whatever BigPipe deferred. A scenario tagged `@bigpipe` gets the -> `big_pipe_nojs` cookie, which makes Drupal render the page in full -> server-side. +> authenticated-user assertion on such a browser driver silently misses the +> content BigPipe deferred. +>

+> A scenario tagged `@bigpipe` gets the `big_pipe_nojs` cookie, so Drupal +> renders the page in full server-side. >

> Skip processing with tag: `@behat-steps-skip:BigPipeTrait`. >

@@ -4596,7 +4605,7 @@ When I wait for the batch job to finish > - `@bigpipe` - render server-side on a browser driver without JavaScript. > > Set the `big_pipe.wait_timeout` option to change the maximum wait, or assign -> `$bigPipeWaitTimeout` to override it for one scenario. +> `$bigPipeWaitTimeout` to override it for 1 scenario. ### Options @@ -4875,16 +4884,20 @@ When I run cron > runtime. They cannot be disabled from the Behat process because tests run > in a separate process from the system under test (SUT). >

-> This trait signals the SUT - through a request header, a `$_SERVER` entry -> and an environment variable - that specific config objects should be read -> from their original (unoverridden) values. The SUT is responsible for -> reading that signal and calling `ImmutableConfig::getOriginal()` instead of -> `ImmutableConfig::get()` for the listed config names. +> This trait signals the SUT that specific config objects should be read +> from their original (unoverridden) values. The signal is a request header, +> a `$_SERVER` entry and an environment variable. +>

+> The SUT is responsible for reading that signal and calling +> `ImmutableConfig::getOriginal()` instead of `ImmutableConfig::get()` for +> the listed config names. >

> Activated by adding `@disable-config-override:CONFIG_NAME` tags to a > feature or scenario. Multiple tags are combined into a comma-separated -> list. Runs on every step because some steps reset headers set earlier in -> the scenario. +> list. +>

+> The signal is applied before every step because some steps reset headers +> set earlier in the scenario. >

> Limitations: > - The request header reaches the SUT only on a browser driver providing @@ -4906,9 +4919,9 @@ When I run cron > ``` >

> The signal is also written to the request-header bag, so a trait that -> issues its own HTTP requests - `RestTrait` - carries it too. The bag is -> per context, so that reaches `RestTrait` only where one context composes -> both; the shipped `WebContext` and `DrupalContext` are separate objects. +> issues its own HTTP requests - `RestTrait` - carries it too. The bag is a +> property of the context object, so the signal reaches `RestTrait` when the +> same context composes both traits, as the shipped `DrupalContext` does. >

> Example: > ``` @@ -4937,13 +4950,12 @@ When I run cron > object's key holds, or contains, an expected value. Nested keys are > addressable with dotted notation (for example `page.front`). >

-> Two families of assertions read the value differently: -> - The default steps read the STORED value via editable configuration, -> ignoring `settings.php` overrides. This is symmetric with the set steps -> and is what most setup-and-assert scenarios need. -> - The `effective` steps read the value through the config factory with -> module and `settings.php` overrides applied - the value the running site -> actually uses. +> 2 families of assertions read the value differently: +> - The default steps read the stored value, with module and `settings.php` +> overrides left unapplied. This is symmetric with the set steps and is +> what most setup-and-assert scenarios need. +> - The `effective` steps read the value with module and `settings.php` +> overrides applied: the value the running site uses. >

> Values are compared by their stringified form, so `true`, `42` and JSON > arrays written in a step match their typed configuration counterparts. The @@ -4951,8 +4963,8 @@ When I run cron > array values, searched recursively. >

> Configuration objects touched by the set steps are snapshotted on first -> write and restored after the scenario: an existing object is reset to its -> original data and an object that did not exist is deleted. Skip the revert +> write and restored after the scenario. An existing object is reset to its +> original data, and an object that did not exist is deleted. Skip the revert > with `@behat-steps-skip:ConfigTrait`. >

> ``` @@ -5478,7 +5490,7 @@ Then the "page" content with the title "Test page" should not be published @When I save the draggable views items of the view :view_id and the display :view_display_id for the :content_type content in the following order:
-Save order of the Draggable Order items +Save the order of the Draggable Views items

```gherkin @@ -5500,9 +5512,9 @@ When I save the draggable views items of the view "draggableviews_demo" and the > - Run a command that is expected to fail and keep its output. > - Assert the last command's output by substring or regular expression. > -> Steps resolve the backend that can run Drush commands rather than the one at -> the front of the scenario's order, so they work in a scenario driven by any -> other backend as long as the suite lists a Drush-capable one. +> Steps resolve the backend that can run Drush commands, not the first one in +> the scenario's order. They work in a scenario driven by any other backend +> as long as the suite lists a Drush-capable one.
@@ -6115,7 +6127,7 @@ Then the file "report.xlsx" should be attached to the email with a subject conta > created here are removed after the scenario along with every other entity > the scenario created. >

-> Skip cleanup for one type with tag: +> Skip cleanup for 1 type with tag: > `@behat-steps-entity-cleanup-skip:commerce_product`. @@ -6284,10 +6296,11 @@ Then an unmanaged file at the URI "public://config.txt" should not contain "debu > - Add languages by their ISO code, skipping ones already installed. > > Languages created here are removed after the scenario along with every other -> entity the scenario created. A scenario that also installs the 'language' -> module leaves that removal to the module uninstall, with -> '@behat-steps-entity-cleanup-skip:language', because the two teardown hooks -> run in no guaranteed order. +> entity the scenario created. +>

+> The 2 teardown hooks run in no guaranteed order. A scenario that also +> installs the 'language' module therefore leaves that removal to the module +> uninstall, with '@behat-steps-entity-cleanup-skip:language'.
@@ -6499,7 +6512,7 @@ Then the "image" media with the name "Test media image" should not exist [Source](src/Steps/Drupal/MenuTrait.php), [Example](tests/behat/features/drupal_menu.feature) -> Manage Drupal menu systems and menu link rendering. +> Manage Drupal menus and menu links. > - Create and remove menus by label. > - Create and remove menu links, including parent-child hierarchies. > - Created menus and menu links are automatically removed at the end of the scenario. @@ -6866,7 +6879,7 @@ Then the queue "myqueue" should be empty [Source](src/Steps/Drupal/RedirectTrait.php), [Example](tests/behat/features/drupal_redirect.feature) > Manage Drupal redirect entities provided by the contrib `redirect` module. -> - Create one or more redirects from a table of source/destination/status. +> - Create 1 or more redirects from a table of source/destination/status. > - Delete redirects by source path. > - Assert that redirects do or do not exist for given source paths. > - Created redirects are automatically removed at the end of the scenario. @@ -6883,7 +6896,7 @@ Then the queue "myqueue" should be empty @Given the following redirects exist:
-Create one or more redirects +Create 1 or more redirects

```gherkin @@ -6917,7 +6930,7 @@ Given the following redirects do not exist: @Then the following redirects should exist:
-Assert that one or more redirects exist +Assert that 1 or more redirects exist

```gherkin @@ -6935,7 +6948,7 @@ Then the following redirects should exist: @Then the following redirects should not exist:
-Assert that no redirect exists for one or more source paths +Assert that no redirect exists for 1 or more source paths

```gherkin @@ -6951,9 +6964,10 @@ Then the following redirects should not exist: [Source](src/Steps/Drupal/SearchApiTrait.php), [Example](tests/behat/features/drupal_search_api.feature) -> Assert Drupal Search API with index and query operations. -> - Add content to an index +> Run Drupal Search API indexing and cron hooks. +> - Add content to an index. > - Run indexing for a specific number of items. +> - Run the Search API and Search API Solr cron hooks. ### Prerequisites @@ -7120,7 +7134,7 @@ Then the state "my_module.launched" should not exist > Manage Drupal taxonomy terms with vocabulary organization. > - Create term vocabulary structures using field values. -> - Navigate to term pages +> - Navigate to term pages. > - Verify vocabulary configurations. @@ -7246,7 +7260,7 @@ Then the vocabulary "topics" should not exist @Then the taxonomy term :term_name from the vocabulary :vocabulary should exist
-Assert that a taxonomy term exist by name +Assert that a taxonomy term exists by name

```gherkin diff --git a/behat.dist.php b/behat.dist.php index 2f45907bb..c9d1c9df4 100644 --- a/behat.dist.php +++ b/behat.dist.php @@ -1,7 +1,10 @@ withExtension(new Extension(BehatStepsExtension::class, [ - // Both the allow-list and the precedence order: a step resolves the first - // backend here that provides the capability the step needs. + // The list is both the allow-list and the precedence order: a step + // resolves the first backend here that provides the capability it needs. 'backends' => ['drupal', 'drush', 'blackbox'], 'login_field' => 'name', 'login_wait' => 0, diff --git a/behat.php b/behat.php index 98ca0348b..6ac27c32b 100644 --- a/behat.php +++ b/behat.php @@ -32,7 +32,7 @@ ]); $default = (new Profile('default', ['autoload' => ['%paths.base%/tests/behat/bootstrap']])) - // Disable the Gherkin cache during development. + // The Gherkin cache is disabled during development. ->withGherkinOptions((new GherkinOptions(['cache' => '']))->withFilter(new TagFilter('~@skipped'))) ->withSuite($suite) ->withExtension(new Extension(MinkExtension::class, [ @@ -95,10 +95,10 @@ ])); } -// Drives headless Chrome directly over the DevTools Protocol, with no Selenium -// server. Run with "behat -p chrome_headless". It inherits the "default" +// The "chrome_headless" profile drives headless Chrome directly over the +// DevTools Protocol, with no Selenium server. It inherits the "default" // profile and swaps only the JavaScript session to "chrome", a session on the -// driver that ChromeExtension registers. +// driver that ChromeExtension registers. Run with "behat -p chrome_headless". $chrome_headless = (new Profile('chrome_headless')) ->withExtension(new Extension(ChromeExtension::class)) ->withExtension(new Extension(MinkExtension::class, ['javascript_session' => 'chrome', 'sessions' => ['chrome' => ['chrome' => ['api_url' => 'http://chrome_headless:9222']]]])); diff --git a/docs.php b/docs.php index 8aa5139b8..a985b6b44 100644 --- a/docs.php +++ b/docs.php @@ -17,7 +17,8 @@ * and that every environment variable the source reads is documented. * * Run with --fail-on-change to fail if the documentation is not up to date. - * Run with --path=path/to/dir to specify a custom path for the output file. + * Run with --path=path/to/dir to use another repository root: the autoloader, + * the sources and the written documents all resolve under it. */ declare(strict_types=1); @@ -264,8 +265,8 @@ function collect_step_traits(array $class_names, array $exclude = [], string $ba sort($traits_files); } - // The scan above is consumed as each trait is found, so the membership test - // below reads a copy taken before that. + // Entries are removed from $traits_files as each trait is found, so the + // membership test below reads a copy taken first. $vocabulary = $traits_files; $collected = []; @@ -294,7 +295,7 @@ function collect_step_traits(array $class_names, array $exclude = [], string $ba // @codeCoverageIgnoreStart if (!$trait_file_path) { - throw new \Exception(sprintf('Trait %s does not have a file path', $trait_name)); + throw new \RuntimeException(sprintf('Trait %s does not have a file path', $trait_name)); } // @codeCoverageIgnoreEnd $relative_path = str_replace($base_path . DIRECTORY_SEPARATOR . STEPS_DIRECTORY . DIRECTORY_SEPARATOR, '', $trait_file_path); @@ -305,7 +306,7 @@ function collect_step_traits(array $class_names, array $exclude = [], string $ba } if (!empty($traits_files)) { - throw new \Exception(sprintf('The following traits were not found in the class: %s', implode(', ', $traits_files))); + throw new \RuntimeException(sprintf('The following traits were not found in the class: %s', implode(', ', $traits_files))); } uksort($collected, strcasecmp(...)); @@ -338,15 +339,15 @@ function collect_helper_traits(string $base_path = __DIR__): array { return $collected; } - foreach (scandir($helpers_path) ?: [] as $half) { - $half_path = $helpers_path . DIRECTORY_SEPARATOR . $half; + foreach (scandir($helpers_path) ?: [] as $context) { + $context_path = $helpers_path . DIRECTORY_SEPARATOR . $context; - if ($half === '.' || $half === '..' || !is_dir($half_path)) { + if ($context === '.' || $context === '..' || !is_dir($context_path)) { continue; } - foreach (scandir($half_path) ?: [] as $file) { - $file_path = $half_path . DIRECTORY_SEPARATOR . $file; + foreach (scandir($context_path) ?: [] as $file) { + $file_path = $context_path . DIRECTORY_SEPARATOR . $file; if (!is_file($file_path) || !file_declares_trait($file_path)) { continue; @@ -354,9 +355,9 @@ function collect_helper_traits(string $base_path = __DIR__): array { $short_name = basename($file, '.php'); /** @var class-string $trait_name */ - $trait_name = 'DrevOps\\BehatSteps\\Helper\\' . $half . '\\' . $short_name; + $trait_name = 'DrevOps\\BehatSteps\\Helper\\' . $context . '\\' . $short_name; - $collected[$short_name] = ['reflection' => new \ReflectionClass($trait_name), 'context' => $half]; + $collected[$short_name] = ['reflection' => new \ReflectionClass($trait_name), 'context' => $context]; } } @@ -518,12 +519,12 @@ function extract_trait_prerequisites(string $class_name, string $trait): array { */ function parse_class_comment(string $trait_name, string $comment): array { if (empty($comment)) { - throw new \Exception(sprintf('Class comment for %s is empty', $trait_name)); + throw new \RuntimeException(sprintf('Class comment for %s is empty', $trait_name)); } $comment = preg_replace('#^/\*\*|^\s*\*\/$#m', '', $comment); $lines = explode(PHP_EOL, (string) $comment); - // Strips the docblock asterisk and at most one space, so any further + // Strips the docblock asterisk and at most 1 space, so any further // indentation is preserved. $lines = array_map(static fn(string $line): string => preg_replace('/^\s*\* ?/', '', $line), $lines); @@ -534,7 +535,6 @@ function parse_class_comment(string $trait_name, string $comment): array { array_pop($lines); } - // Lines are trimmed except inside @code blocks, where indentation is kept. $in_code_block = FALSE; $lines = array_map(static function (string $line) use (&$in_code_block): string { if (str_starts_with(trim($line), '@code')) { @@ -564,22 +564,22 @@ function parse_class_comment(string $trait_name, string $comment): array { // @codeCoverageIgnoreStart if (empty($lines)) { - throw new \Exception(sprintf('Class comment for %s is empty', $trait_name)); + throw new \RuntimeException(sprintf('Class comment for %s is empty', $trait_name)); } // @codeCoverageIgnoreEnd $description = $lines[0]; if (empty($description)) { - throw new \Exception(sprintf('Class comment for %s is empty', $trait_name)); + throw new \RuntimeException(sprintf('Class comment for %s is empty', $trait_name)); } if (str_starts_with($description, 'Trait ')) { - throw new \Exception(sprintf('Class comment should have a descriptive content for %s', $trait_name)); + throw new \RuntimeException(sprintf('Class comment should have a descriptive content for %s', $trait_name)); } $full_description = implode(PHP_EOL, $lines); if (substr_count($full_description, '`') % 2 !== 0) { - throw new \Exception(sprintf('Class inline code block is not closed for %s', $trait_name)); + throw new \RuntimeException(sprintf('Class inline code block is not closed for %s', $trait_name)); } return [ @@ -645,14 +645,12 @@ function parse_method_comment(string $comment): ?array { } if ($example_start) { - throw new \Exception('Example not closed'); + throw new \RuntimeException('Example not closed'); } $return['description'] = trim($return['description']); if (!empty($return['example'])) { - // Indentation is removed from the example, with the first line as the - // reference. $lines = explode(PHP_EOL, $return['example']); $first_line = ''; foreach ($lines as $line) { @@ -736,7 +734,7 @@ function method_is_registered(\ReflectionMethod $method): bool { } /** - * Check whether a docblock withdraws the member from the published API. + * Check whether a docblock excludes the member from the published API. * * @param string $comment * The docblock comment. @@ -1000,19 +998,19 @@ function extract_helpers(array $class_names, array $exclude = [], string $base_p foreach (collect_helper_traits($base_path) as $trait_name => $collected) { $trait = $collected['reflection']; - $half = $collected['context']; + $context = $collected['context']; $helpers = collect_helper_methods($trait, NULL, NULL, helper_trait_contracts($trait, $class_names)); // @codeCoverageIgnoreStart if ($helpers === []) { continue; } // @codeCoverageIgnoreEnd - $name_contextual = ($half !== DEFAULT_CONTEXT ? $half . '\\' : '') . $trait_name; + $name_contextual = ($context !== DEFAULT_CONTEXT ? $context . '\\' : '') . $trait_name; $class_info = [ 'name' => $trait_name, 'name_contextual' => $name_contextual, - 'context' => $half, - 'source' => sprintf('%s/%s/%s.php', HELPERS_DIRECTORY, $half, $trait_name), + 'context' => $context, + 'source' => sprintf('%s/%s/%s.php', HELPERS_DIRECTORY, $context, $trait_name), 'steps_anchor' => NULL, 'helpers' => $helpers, ]; @@ -1050,7 +1048,7 @@ function extract_helpers(array $class_names, array $exclude = [], string $base_p } /** - * List the contracts a helper trait answers to. + * List the contracts the classes composing a helper trait declare. * * A trait cannot implement an interface, so the class composing it declares * the contract instead. A trait method carrying '{@inheritdoc}' is documented @@ -1114,8 +1112,8 @@ function composes_trait(\ReflectionClass $reflection, string $trait_name): bool * Collect the toolbox methods a class or trait contributes. * * Visibility is the marker: a public method that Behat does not register is - * the toolbox, and a protected one is an implementation detail carrying no - * promise to a consuming project. + * part of the toolbox. A protected method is an implementation detail with + * no contract to a consuming project. * * @param \ReflectionClass $reflection * The class or trait reflection. @@ -1243,7 +1241,7 @@ function relative_source_path(string $file_path, string $base_path = __DIR__): s * Convert info to content. * * @param array|string>>|string>> $info - * Array of info items with 'name', 'from', and 'to' keys. + * The extracted trait info from extract_info(). * @param string $base_path * Base path for the repository. * @@ -1255,14 +1253,14 @@ function render_info(array $info, string $base_path = __DIR__, ?string $path_for $index_rows = []; - foreach ($info as $trait => $trait_info) { - $context = $trait_info['context']; + foreach ($info as $trait => $class_info) { + $context = $class_info['context']; // @phpstan-ignore-next-line $src_file = sprintf('%s/%s/%s.php', STEPS_DIRECTORY, $context, $trait); $src_file_path = $base_path . DIRECTORY_SEPARATOR . $src_file; if (!file_exists($src_file_path)) { - throw new \Exception(sprintf('Source file %s does not exist', $src_file_path)); + throw new \RuntimeException(sprintf('Source file %s does not exist', $src_file_path)); } $example_name = camel_to_snake(str_replace('Trait', '', $trait)); @@ -1275,19 +1273,19 @@ function render_info(array $info, string $base_path = __DIR__, ?string $path_for // @codeCoverageIgnoreStart if (!file_exists($example_file_path)) { - throw new \Exception(sprintf('Example file %s does not exist', $example_file_path)); + throw new \RuntimeException(sprintf('Example file %s does not exist', $example_file_path)); } // @codeCoverageIgnoreEnd // @phpstan-ignore-next-line $content_output[$context] ??= ''; // @phpstan-ignore-next-line - $content_output[$context] .= sprintf('## %s', $trait_info['name_contextual']) . PHP_EOL . PHP_EOL; + $content_output[$context] .= sprintf('## %s', $class_info['name_contextual']) . PHP_EOL . PHP_EOL; // @phpstan-ignore-next-line $content_output[$context] .= sprintf('[Source](%s), [Example](%s)', $src_file, $example_file) . PHP_EOL . PHP_EOL; $description_full = ''; // @phpstan-ignore-next-line - $lines = explode(PHP_EOL, $trait_info['description_full']); + $lines = explode(PHP_EOL, $class_info['description_full']); $was_list = FALSE; $in_code_block = FALSE; $code_block = ''; @@ -1339,23 +1337,23 @@ function render_info(array $info, string $base_path = __DIR__, ?string $path_for // @phpstan-ignore-next-line $content_output[$context] .= $description_full . PHP_EOL . PHP_EOL; // @phpstan-ignore-next-line - $content_output[$context] .= render_trait_prerequisites($trait_info['prerequisites'] ?? []); + $content_output[$context] .= render_trait_prerequisites($class_info['prerequisites'] ?? []); // @phpstan-ignore-next-line - $content_output[$context] .= render_trait_options($trait, $trait_info['options'] ?? []); + $content_output[$context] .= render_trait_options($trait, $class_info['options'] ?? []); // @phpstan-ignore-next-line - $index_rows_path = '#' . heading_anchor((string) $trait_info['name_contextual']); + $index_rows_path = '#' . heading_anchor((string) $class_info['name_contextual']); if ($path_for_links) { $index_rows_path = $path_for_links . $index_rows_path; } // @phpstan-ignore-next-line $index_rows[$context][] = [ // @phpstan-ignore-next-line - sprintf('[%s](%s)', $trait_info['name_contextual'], $index_rows_path), - $trait_info['description'], + sprintf('[%s](%s)', $class_info['name_contextual'], $index_rows_path), + $class_info['description'], ]; // @phpstan-ignore-next-line - foreach ($trait_info['methods'] as $method) { + foreach ($class_info['methods'] as $method) { $method['steps'] = is_array($method['steps']) ? $method['steps'] : [$method['steps']]; $method['description'] = is_string($method['description']) ? $method['description'] : ''; $method['example'] = is_string($method['example']) ? $method['example'] : ''; @@ -1443,18 +1441,18 @@ function render_helpers(array $info, string $base_path = __DIR__): string { $content_output = []; $index_rows = []; - foreach ($info as $trait_info) { - $context = (string) $trait_info['context']; - $name_contextual = (string) $trait_info['name_contextual']; + foreach ($info as $class_info) { + $context = (string) $class_info['context']; + $name_contextual = (string) $class_info['name_contextual']; $anchor = heading_anchor($name_contextual); - $helpers = is_array($trait_info['helpers']) ? $trait_info['helpers'] : []; + $helpers = is_array($class_info['helpers']) ? $class_info['helpers'] : []; - $src_file = (string) $trait_info['source']; + $src_file = (string) $class_info['source']; if (!file_exists($base_path . DIRECTORY_SEPARATOR . $src_file)) { - throw new \Exception(sprintf('Source file %s does not exist', $base_path . DIRECTORY_SEPARATOR . $src_file)); + throw new \RuntimeException(sprintf('Source file %s does not exist', $base_path . DIRECTORY_SEPARATOR . $src_file)); } - $steps_anchor = $trait_info['steps_anchor'] ?? NULL; + $steps_anchor = $class_info['steps_anchor'] ?? NULL; $links = sprintf('[Source](%s)', $src_file); if (is_string($steps_anchor)) { $links .= sprintf(', [Steps](STEPS.md#%s)', $steps_anchor); @@ -1463,7 +1461,7 @@ function render_helpers(array $info, string $base_path = __DIR__): string { $content_output[$context] ??= ''; $content_output[$context] .= sprintf('## %s', $name_contextual) . PHP_EOL . PHP_EOL; $content_output[$context] .= $links . PHP_EOL . PHP_EOL; - $content_output[$context] .= '> ' . $trait_info['description'] . PHP_EOL . PHP_EOL; + $content_output[$context] .= '> ' . $class_info['description'] . PHP_EOL . PHP_EOL; foreach ($helpers as $helper) { $example = (string) $helper['example']; @@ -1484,7 +1482,7 @@ function render_helpers(array $info, string $base_path = __DIR__): string { $index_rows[$context][] = [ sprintf('[%s](#%s)', $name_contextual, $anchor), (string) count($helpers), - (string) $trait_info['description'], + (string) $class_info['description'], ]; } @@ -1518,10 +1516,10 @@ function render_helpers(array $info, string $base_path = __DIR__): string { function validate_helpers(array $info): array { $errors = []; - foreach ($info as $trait_info) { - $class_name = is_string($trait_info['name'] ?? NULL) ? $trait_info['name'] : ''; + foreach ($info as $class_info) { + $class_name = is_string($class_info['name'] ?? NULL) ? $class_info['name'] : ''; - foreach ((is_array($trait_info['helpers'] ?? NULL) ? $trait_info['helpers'] : []) as $helper) { + foreach ((is_array($class_info['helpers'] ?? NULL) ? $class_info['helpers'] : []) as $helper) { $name = is_string($helper['name'] ?? NULL) ? $helper['name'] : ''; $description = is_string($helper['description'] ?? NULL) ? $helper['description'] : ''; @@ -1538,7 +1536,7 @@ function validate_helpers(array $info): array { * Validate the info. * * @param array|string>>|string>> $info - * Array of info items with 'name', 'from', and 'to' keys. + * The extracted trait info from extract_info(). * * @return array * Array of errors. @@ -1587,7 +1585,7 @@ function validate(array $info): array { } if (str_contains((string) $method['name'], 'Should')) { - $errors[] = sprintf(' %s::%s - %s' . PHP_EOL, $class_name, $method['name'], 'Assert method contains "Should" but should not.'); + $errors[] = sprintf(' %s::%s - %s' . PHP_EOL, $class_name, $method['name'], 'Assert method contains "Should", but it should not.'); } if (!str_contains($step, ' should ')) { @@ -1702,7 +1700,6 @@ function validate_step_patterns(array $info): array { } } - // Every documented step text is collected once, with the step that owns it. $examples = []; foreach ($steps as $step) { foreach ($step['examples'] as $example) { @@ -2158,18 +2155,18 @@ function validate_tags(array $info, string $base_path = __DIR__): array { $registry = tag_registry(); $errors = []; - foreach ($info as $trait => $trait_info) { - if (!is_array($trait_info)) { + foreach ($info as $trait => $class_info) { + if (!is_array($class_info)) { continue; } - $label = is_string($trait_info['name'] ?? NULL) ? $trait_info['name'] : (string) $trait; + $label = is_string($class_info['name'] ?? NULL) ? $class_info['name'] : (string) $trait; $texts = []; - if (is_string($trait_info['description_full'] ?? NULL)) { - $texts[] = $trait_info['description_full']; + if (is_string($class_info['description_full'] ?? NULL)) { + $texts[] = $class_info['description_full']; } - foreach ((is_array($trait_info['methods'] ?? NULL) ? $trait_info['methods'] : []) as $method) { + foreach ((is_array($class_info['methods'] ?? NULL) ? $class_info['methods'] : []) as $method) { if (is_array($method) && is_string($method['example'] ?? NULL)) { $texts[] = $method['example']; } @@ -2249,15 +2246,15 @@ function camel_to_snake(string $string, string $separator = '_'): string { */ function replace_content(string $haystack, string $start, string $end, string $replacement): string { if (!str_contains($haystack, $start)) { - throw new \Exception('Start not found in the haystack'); + throw new \RuntimeException('Start not found in the haystack'); } if (!str_contains($haystack, $end)) { - throw new \Exception('End not found in the haystack'); + throw new \RuntimeException('End not found in the haystack'); } if (strpos($haystack, $start) > strpos($haystack, $end)) { - throw new \Exception('Start is after the end'); + throw new \RuntimeException('Start is after the end'); } $pattern = '/' . preg_quote($start, '/') . '.*?' . preg_quote($end, '/') . '/s'; diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8899c5199..89733e348 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -100,7 +100,7 @@ The context layer is one chain. `WebRawContext` is the root and registers no ste `WebContext` extends it and composes every trait under `src/Steps/Web`. `DrupalContext` extends `WebContext` and composes every trait under `src/Steps/Drupal`. The Drupal scenario lifecycle does not sit on the context: each step trait composes the helper traits it needs, so the lifecycle arrives with the traits that use it: - Entity creation (`entityLifecycleNodeCreate`, `authUserCreate`, `entityLifecycleTermCreate`, `entityLifecycleCreate`, `entityLifecycleLanguageCreate`), each dispatching before/after hooks so a project can adjust a stub in flight. -- Cleanup: `entityLifecycleCleanAll`, `authCleanUsers` and `authCleanRoles` run after the scenario and delete what it created, in reverse. +- Cleanup: `entityLifecycleAfterScenario`, `authCleanUsers` and `authCleanRoles` run after the scenario and delete what it created, in reverse. - Authentication: `authLogin`, `authLogout`, `authIsLoggedIn`, delegated to `Authenticator`. Every helper member carries its trait's prefix, so two helpers mixed into one context cannot collide and a reader can tell from a call site which trait has to be composed. diff --git a/docs/architecture/class-browser.puml b/docs/architecture/class-browser.puml index 3dfa8ff8f..12128fa11 100644 --- a/docs/architecture/class-browser.puml +++ b/docs/architecture/class-browser.puml @@ -17,7 +17,7 @@ package "DrevOps\\BehatSteps\\Behat\\Context" #F3E5F5 { class WebRawContext { +browserDriverFor(capability) : object +browserDriverHas(capability) : bool - +getBrowserResolver() : BrowserCapabilityResolver + +getBrowserCapabilityResolver() : BrowserCapabilityResolver -- +httpPageClient() : AbstractBrowser +httpDetachedClient(options) : AbstractBrowser diff --git a/docs/architecture/class-browser.svg b/docs/architecture/class-browser.svg index 737519fb1..e1e205362 100644 --- a/docs/architecture/class-browser.svg +++ b/docs/architecture/class-browser.svg @@ -1 +1 @@ -Class structure: browser capabilities and HTTP clientsDrevOps\BehatSteps\Behat\ContextDrevOps\BehatSteps\Behat\MinkDrevOps\BehatSteps\Behat\Mink\AdapterDrevOps\BehatSteps\Behat\Mink\CapabilityDrevOps\BehatSteps\Behat\HttpDrevOps\BehatSteps\Behat\ServiceContainerDrevOps\BehatSteps\Behat\Mink\ServiceContainer\DriverSymfony and MinkWebRawContext+browserDriverFor(capability) : object+browserDriverHas(capability) : bool+getBrowserResolver() : BrowserCapabilityResolver+httpPageClient() : AbstractBrowser+httpDetachedClient(options) : AbstractBrowser+httpBareClient(options) : AbstractBrowser+getHttpClientFactory() : HttpClientFactoryInterface#httpIdentity() : HttpIdentityBrowserCapabilityResolver+registerAdapter(adapter) : void+resolve(driver, capability) : object+has(driver, capability) : boolBrowserAdapterInterface+supports(driver) : boolBrowserAdapterBase#driver : Mink DriverInterfaceBrowserKitAdapterChromeAdapterSelenium2AdapterCookieCapabilityInterface+cookieGetAll() : arrayHttpClientCapabilityInterface+httpClient() : AbstractBrowserJavascriptCapabilityInterfacemarker: the browser driver runs JavaScriptKeyboardCapabilityInterface+keyboardTriggerKey(xpath, key) : voidRequestHeaderCapabilityInterface+requestHeaderSet(name, value) : voidHttpClientFactoryInterface+createBare(options) : AbstractBrowser+createDetached(identity, options) : AbstractBrowser+withTransport(decorator) : staticHttpClientFactory#transport : HttpClientInterface#baseUrl : string|null+createTransport(options, base_url, client) : HttpClientInterface#sitePattern(base_url) : string|null#identityOptions(identity) : array#cookieJar(identity) : CookieJarHttpIdentity+cookies : array+cookieUrl : string+headers : array+credentials : array|nullBehatStepsExtension+TRANSPORT_SERVICE : string+initialize(extensionManager) : void+process(container) : void#initializeBrowserKitFactory(extension_manager) : void#processHttpClient(container) : voidBrowserKitFactory+configure(builder) : void+buildDriver(config) : Definition+getClientOptions() : arrayAbstractBrowserfrom Symfony BrowserKitHttpBrowserScopingHttpClientfrom Symfony HttpClientMink BrowserKitFactoryfrom Behat\MinkExtensionThe page client is a capability, because onlya BrowserKit session has one. The detached andbare clients exist under every browser driver,so a factory builds them instead.Headers and credentials go to the base_urlhost only. Cookies go to the host they wereread from and its subdomains.offers each browser driver to,registered adapters firsttakesbuilds from cookies,the header bag andthe base_url credentialslends out thesession's ownbuilds a fresh oneper detached or bare clientapplies site options to base_url only,and a client's own options over thembuilds each browserkit_http sessionon a client of its own, as Mink doesregisters with Mink,reads the session optionsdefines behat_steps.http_clientwith createTransport() \ No newline at end of file +Class structure: browser capabilities and HTTP clientsDrevOps\BehatSteps\Behat\ContextDrevOps\BehatSteps\Behat\MinkDrevOps\BehatSteps\Behat\Mink\AdapterDrevOps\BehatSteps\Behat\Mink\CapabilityDrevOps\BehatSteps\Behat\HttpDrevOps\BehatSteps\Behat\ServiceContainerDrevOps\BehatSteps\Behat\Mink\ServiceContainer\DriverSymfony and MinkWebRawContext+browserDriverFor(capability) : object+browserDriverHas(capability) : bool+getBrowserCapabilityResolver() : BrowserCapabilityResolver+httpPageClient() : AbstractBrowser+httpDetachedClient(options) : AbstractBrowser+httpBareClient(options) : AbstractBrowser+getHttpClientFactory() : HttpClientFactoryInterface#httpIdentity() : HttpIdentityBrowserCapabilityResolver+registerAdapter(adapter) : void+resolve(driver, capability) : object+has(driver, capability) : boolBrowserAdapterInterface+supports(driver) : boolBrowserAdapterBase#driver : Mink DriverInterfaceBrowserKitAdapterChromeAdapterSelenium2AdapterCookieCapabilityInterface+cookieGetAll() : arrayHttpClientCapabilityInterface+httpClient() : AbstractBrowserJavascriptCapabilityInterfacemarker: the browser driver runs JavaScriptKeyboardCapabilityInterface+keyboardTriggerKey(xpath, key) : voidRequestHeaderCapabilityInterface+requestHeaderSet(name, value) : voidHttpClientFactoryInterface+createBare(options) : AbstractBrowser+createDetached(identity, options) : AbstractBrowser+withTransport(decorator) : staticHttpClientFactory#transport : HttpClientInterface#baseUrl : string|null+createTransport(options, base_url, client) : HttpClientInterface#sitePattern(base_url) : string|null#identityOptions(identity) : array#cookieJar(identity) : CookieJarHttpIdentity+cookies : array+cookieUrl : string+headers : array+credentials : array|nullBehatStepsExtension+TRANSPORT_SERVICE : string+initialize(extensionManager) : void+process(container) : void#initializeBrowserKitFactory(extension_manager) : void#processHttpClient(container) : voidBrowserKitFactory+configure(builder) : void+buildDriver(config) : Definition+getClientOptions() : arrayAbstractBrowserfrom Symfony BrowserKitHttpBrowserScopingHttpClientfrom Symfony HttpClientMink BrowserKitFactoryfrom Behat\MinkExtensionThe page client is a capability, because onlya BrowserKit session has one. The detached andbare clients exist under every browser driver,so a factory builds them instead.Headers and credentials go to the base_urlhost only. Cookies go to the host they wereread from and its subdomains.offers each browser driver to,registered adapters firsttakesbuilds from cookies,the header bag andthe base_url credentialslends out thesession's ownbuilds a fresh oneper detached or bare clientapplies site options to base_url only,and a client's own options over thembuilds each browserkit_http sessionon a client of its own, as Mink doesregisters with Mink,reads the session optionsdefines behat_steps.http_clientwith createTransport() \ No newline at end of file diff --git a/docs/architecture/class-context.puml b/docs/architecture/class-context.puml index 1e90fb1b2..2702527e5 100644 --- a/docs/architecture/class-context.puml +++ b/docs/architecture/class-context.puml @@ -213,7 +213,7 @@ package "DrevOps\\BehatSteps\\Helper\\Drupal" #F3E5F5 { +entityLifecycleLanguageCreate(stub) : EntityStubInterface|false +entityLifecycleRegister(entity) : void -- - +entityLifecycleCleanAll(scope) : void + +entityLifecycleAfterScenario(scope) : void -- #entityLifecycleDispatchHooks(scopeClass, stub) : void } @@ -229,7 +229,7 @@ package "DrevOps\\BehatSteps\\Helper\\Drupal" #F3E5F5 { } class StaticCacheTrait <> { - +staticCacheClear(scope) : void + +staticCacheAfterScenario(scope) : void } class FixtureFileTrait <> { diff --git a/docs/architecture/class-context.svg b/docs/architecture/class-context.svg index 03ee05c9e..8cfb4e6e2 100644 --- a/docs/architecture/class-context.svg +++ b/docs/architecture/class-context.svg @@ -1 +1 @@ -Class detail: context, services, helpers and step traitsDrevOps\BehatSteps\Behat\ContextBehat\MinkExtension\ContextDrevOps\BehatSteps\Behat\ManagerDrevOps\BehatSteps\Behat\ConfigDrevOps\BehatSteps\Behat\PrerequisiteDrevOps\BehatSteps\Helper\WebDrevOps\BehatSteps\Helper\DrupalSteps\WebSteps\DrupalThrown on failureWebRawContext+getBackend(name) : BackendInterface+backendFor(capability) : object+getRandom() : Random+getOption(group, key) : mixed+getOptionBool(group, key) : bool+getOptionInt(group, key) : int+getOptionFloat(group, key) : float+getOptionString(group, key) : string+getOptionArray(group, key) : array+getOptionResolver() : TraitOptionResolverInterface+getBasicAuthenticator() : BasicAuthenticatorInterface+browserDriverFor(capability) : object+httpPageClient() : AbstractBrowser+httpDetachedClient(options) : AbstractBrowser+httpBareClient(options) : AbstractBrowser#skipTag(trait, scope) : bool#anyBackendFor(capability) : object#assertPrerequisites(trait) : void#prerequisitesMet(trait) : bool#shouldCleanup() : bool#buildOptionResolver() : TraitOptionResolverInterfacecomposes 3 of the web helper traitsWebContextuse every Steps\Web trait+assertOneContext(scope) : voidDrupalContextuse every Steps\Drupal traitUserAwareInterface+authSetUserRegistry(registry) : void+authGetUserRegistry() : UserRegistryInterface+authSetAuthenticator(authenticator) : void+authGetAuthenticator() : AuthenticatorInterfaceRawMinkContext+getSession() : Session+getMinkParameter(name) : mixedBackendRegistryInterface+getBackendFor(capability) : object+getResolvedBackendFor(capability) : object|null+hasCapability(capability) : bool+setScenarioBackends(backends) : voidBasicAuthenticatorInterface+applyBasicAuth() : void+findCredentials() : array|nullAuthenticatorInterface+logIn(user) : void+logOut() : void+loggedIn() : boolUserRegistryInterface+getCurrentUser() : EntityStubInterface|false+addUser(user) : void+currentUserHasRole(role) : boolScenarioTagRegistryInterface+setTags(tags) : void+getTags() : arrayTraitOptionResolverFactoryInterface+create(context_class, config, steps) : TraitOptionResolverInterfaceTraitOptionResolverInterface+has(group, key) : bool+raw(group, key) : mixed+bool(group, key) : bool+int(group, key) : int+float(group, key) : float+string(group, key) : string+array(group, key) : array+groupFor(trait) : string|nullOption+name : string+default : mixed+description : string+tags : array+cast(value, path) : mixedConfigSchemaReader+read(context_class) : arraythe only piece that reflectsTagOverrides+SKIP_TAG_PREFIX : string+SKIP_TAG_VALUE_PATTERN : string+apply(group, option, value, tags) : mixedPrerequisite+capability : string+check : Closure|null+description : string+capability(capability, description) : Prerequisite+check(check, description) : PrerequisitePrerequisiteReader+methodFor(trait) : string+read(context, trait) : arraycaches declarations, never answers«trait»RequestHeadersTrait+requestHeadersSet(name, value) : void#requestHeadersUnset(name) : void#requestHeadersAll() : array#requestHeadersReset() : void«trait»StringTrait#stringFixStepArgument(argument) : string#stringNormalizeWhitespace(text) : string#stringSplitCommaSeparated(text) : array#stringSlug(value) : string«trait»LastStepTrait#lastStepSetLine(scope) : void#lastStepReached(scope) : bool«trait»TableTransposeTrait+tableTransposeVertical(table) : array+tableTransposeHorizontal(entities) : TableNode«trait»EntityLifecycleTrait+entityLifecycleNodeCreate(stub) : EntityStubInterface+entityLifecycleTermCreate(stub) : EntityStubInterface+entityLifecycleCreate(stub) : EntityStubInterface+entityLifecycleLanguageCreate(stub) : EntityStubInterface|false+entityLifecycleRegister(entity) : void+entityLifecycleCleanAll(scope) : void#entityLifecycleDispatchHooks(scopeClass, stub) : void«trait»AuthTrait+authUserCreate(stub) : EntityStubInterface+authLogin(user) : void+authLogout(fast) : void+authIsLoggedIn() : bool+authCleanUsers(scope) : void+authCleanRoles(scope) : void«trait»StaticCacheTrait+staticCacheClear(scope) : void«trait»FixtureFileTrait+fixtureFileExpandEntityFields(entity_type, stub) : void«trait»QueryTrait+queryEntityIds(entity_type, conditions, bundle) : array+queryNodeIds(content_type, conditions) : array«trait»PathTrait+pathSetBasicAuth(username, password) : void+pathVisit(path) : void+pathGoBack() : void+pathAssertCurrent(path) : void+pathAssertUrlParameterExists(name) : void«trait»CommandTrait+commandRun(command) : void+commandAssertSuccess() : void+commandAssertExitCode(code) : void+commandAssertOutputContains(value) : void«trait»ContentTraitExpectationExceptionfrom Behat\Mink+__construct(message, browser driver)ElementNotFoundExceptionAssertionExceptionDrevOps\BehatSteps\Exceptionno browser driver requiredUnsupportedBackendActionExceptionDrevOps\BehatSteps\Backend\Exceptionno backend in the scenario'sorder provides the capabilityUnsupportedDriverActionExceptionfrom Behat\Minkthe session's browser driverlacks the capabilityRuntimeExceptioninvalid argument orunmet prerequisiteNever calls getSession(), so it cannotpass a browser driver to ExpectationException.It throws AssertionException instead.Entity cleanup lives with the concern thatcreates entities. Every step trait that createsone composes this, and trait flattening isidempotent, so there is still one registry andone reverse-order deletion pass. A step names a capability through backendFor()and never a backend, so the vocabulary carriesover to a backend a project registers itself.A helper trait composed by a step trait and bythe context under it holds one property slot,so both reach the same state. PathTrait records basic-auth credentials here,and the detached HTTP client sends every headerin it to the base_url host.Applying credentials to a request needs Mink anda base URL, so BasicAuthenticator is separate fromthe Drupal session services and WebRawContextcarries only this one.A resolver depends on the context class that declaredthe options and on the config argument that context wasgiven, so it is built per context rather than shared.Registering another factory replaces option resolutionfor every context at once. BackendListener fills ScenarioTagRegistry before the firstBeforeScenario hook, so a tag that sets an option reachesa step as well as a hook. A skip tag names a trait. skipTag() reads it together withthe trait's enabled option, and SkipTagListener fails therun at scenario start on a value that is not a trait name.A trait declares what it needs from the site in<prefix>Prerequisites(), each entry through a backendcapability. A setup hook or a step callsassertPrerequisites(), which throws for the first entrythat does not hold. A teardown asks prerequisitesMet(),so it never replaces a failure already recorded. Each check runs against a backend the scenario alreadyreached before the first one listed, so checking neverstarts a second backend.buildsreadsreadschecks prerequisitescomposescomposescomposescomposesrequirescomposesrequiresrequirescomposescomposescomposescomposesthrowsthrowsthrowsthrows \ No newline at end of file +Class detail: context, services, helpers and step traitsDrevOps\BehatSteps\Behat\ContextBehat\MinkExtension\ContextDrevOps\BehatSteps\Behat\ManagerDrevOps\BehatSteps\Behat\ConfigDrevOps\BehatSteps\Behat\PrerequisiteDrevOps\BehatSteps\Helper\WebDrevOps\BehatSteps\Helper\DrupalSteps\WebSteps\DrupalThrown on failureWebRawContext+getBackend(name) : BackendInterface+backendFor(capability) : object+getRandom() : Random+getOption(group, key) : mixed+getOptionBool(group, key) : bool+getOptionInt(group, key) : int+getOptionFloat(group, key) : float+getOptionString(group, key) : string+getOptionArray(group, key) : array+getOptionResolver() : TraitOptionResolverInterface+getBasicAuthenticator() : BasicAuthenticatorInterface+browserDriverFor(capability) : object+httpPageClient() : AbstractBrowser+httpDetachedClient(options) : AbstractBrowser+httpBareClient(options) : AbstractBrowser#skipTag(trait, scope) : bool#anyBackendFor(capability) : object#assertPrerequisites(trait) : void#prerequisitesMet(trait) : bool#shouldCleanup() : bool#buildOptionResolver() : TraitOptionResolverInterfacecomposes 3 of the web helper traitsWebContextuse every Steps\Web trait+assertOneContext(scope) : voidDrupalContextuse every Steps\Drupal traitUserAwareInterface+authSetUserRegistry(registry) : void+authGetUserRegistry() : UserRegistryInterface+authSetAuthenticator(authenticator) : void+authGetAuthenticator() : AuthenticatorInterfaceRawMinkContext+getSession() : Session+getMinkParameter(name) : mixedBackendRegistryInterface+getBackendFor(capability) : object+getResolvedBackendFor(capability) : object|null+hasCapability(capability) : bool+setScenarioBackends(backends) : voidBasicAuthenticatorInterface+applyBasicAuth() : void+findCredentials() : array|nullAuthenticatorInterface+logIn(user) : void+logOut() : void+loggedIn() : boolUserRegistryInterface+getCurrentUser() : EntityStubInterface|false+addUser(user) : void+currentUserHasRole(role) : boolScenarioTagRegistryInterface+setTags(tags) : void+getTags() : arrayTraitOptionResolverFactoryInterface+create(context_class, config, steps) : TraitOptionResolverInterfaceTraitOptionResolverInterface+has(group, key) : bool+raw(group, key) : mixed+bool(group, key) : bool+int(group, key) : int+float(group, key) : float+string(group, key) : string+array(group, key) : array+groupFor(trait) : string|nullOption+name : string+default : mixed+description : string+tags : array+cast(value, path) : mixedConfigSchemaReader+read(context_class) : arraythe only piece that reflectsTagOverrides+SKIP_TAG_PREFIX : string+SKIP_TAG_VALUE_PATTERN : string+apply(group, option, value, tags) : mixedPrerequisite+capability : string+check : Closure|null+description : string+capability(capability, description) : Prerequisite+check(check, description) : PrerequisitePrerequisiteReader+methodFor(trait) : string+read(context, trait) : arraycaches declarations, never answers«trait»RequestHeadersTrait+requestHeadersSet(name, value) : void#requestHeadersUnset(name) : void#requestHeadersAll() : array#requestHeadersReset() : void«trait»StringTrait#stringFixStepArgument(argument) : string#stringNormalizeWhitespace(text) : string#stringSplitCommaSeparated(text) : array#stringSlug(value) : string«trait»LastStepTrait#lastStepSetLine(scope) : void#lastStepReached(scope) : bool«trait»TableTransposeTrait+tableTransposeVertical(table) : array+tableTransposeHorizontal(entities) : TableNode«trait»EntityLifecycleTrait+entityLifecycleNodeCreate(stub) : EntityStubInterface+entityLifecycleTermCreate(stub) : EntityStubInterface+entityLifecycleCreate(stub) : EntityStubInterface+entityLifecycleLanguageCreate(stub) : EntityStubInterface|false+entityLifecycleRegister(entity) : void+entityLifecycleAfterScenario(scope) : void#entityLifecycleDispatchHooks(scopeClass, stub) : void«trait»AuthTrait+authUserCreate(stub) : EntityStubInterface+authLogin(user) : void+authLogout(fast) : void+authIsLoggedIn() : bool+authCleanUsers(scope) : void+authCleanRoles(scope) : void«trait»StaticCacheTrait+staticCacheAfterScenario(scope) : void«trait»FixtureFileTrait+fixtureFileExpandEntityFields(entity_type, stub) : void«trait»QueryTrait+queryEntityIds(entity_type, conditions, bundle) : array+queryNodeIds(content_type, conditions) : array«trait»PathTrait+pathSetBasicAuth(username, password) : void+pathVisit(path) : void+pathGoBack() : void+pathAssertCurrent(path) : void+pathAssertUrlParameterExists(name) : void«trait»CommandTrait+commandRun(command) : void+commandAssertSuccess() : void+commandAssertExitCode(code) : void+commandAssertOutputContains(value) : void«trait»ContentTraitExpectationExceptionfrom Behat\Mink+__construct(message, browser driver)ElementNotFoundExceptionAssertionExceptionDrevOps\BehatSteps\Exceptionno browser driver requiredUnsupportedBackendActionExceptionDrevOps\BehatSteps\Backend\Exceptionno backend in the scenario'sorder provides the capabilityUnsupportedDriverActionExceptionfrom Behat\Minkthe session's browser driverlacks the capabilityRuntimeExceptioninvalid argument orunmet prerequisiteNever calls getSession(), so it cannotpass a browser driver to ExpectationException.It throws AssertionException instead.Entity cleanup lives with the concern thatcreates entities. Every step trait that createsone composes this, and trait flattening isidempotent, so there is still one registry andone reverse-order deletion pass. A step names a capability through backendFor()and never a backend, so the vocabulary carriesover to a backend a project registers itself.A helper trait composed by a step trait and bythe context under it holds one property slot,so both reach the same state. PathTrait records basic-auth credentials here,and the detached HTTP client sends every headerin it to the base_url host.Applying credentials to a request needs Mink anda base URL, so BasicAuthenticator is separate fromthe Drupal session services and WebRawContextcarries only this one.A resolver depends on the context class that declaredthe options and on the config argument that context wasgiven, so it is built per context rather than shared.Registering another factory replaces option resolutionfor every context at once. BackendListener fills ScenarioTagRegistry before the firstBeforeScenario hook, so a tag that sets an option reachesa step as well as a hook. A skip tag names a trait. skipTag() reads it together withthe trait's enabled option, and SkipTagListener fails therun at scenario start on a value that is not a trait name.A trait declares what it needs from the site in<prefix>Prerequisites(), each entry through a backendcapability. A setup hook or a step callsassertPrerequisites(), which throws for the first entrythat does not hold. A teardown asks prerequisitesMet(),so it never replaces a failure already recorded. Each check runs against a backend the scenario alreadyreached before the first one listed, so checking neverstarts a second backend.buildsreadsreadschecks prerequisitescomposescomposescomposescomposesrequirescomposesrequiresrequirescomposescomposescomposescomposesthrowsthrowsthrowsthrows \ No newline at end of file diff --git a/docs/architecture/dataflow-step.puml b/docs/architecture/dataflow-step.puml index 3d4cd7b3c..24bfbfb3f 100644 --- a/docs/architecture/dataflow-step.puml +++ b/docs/architecture/dataflow-step.puml @@ -111,7 +111,7 @@ Behat -> Ctx: AfterScenario activate Ctx Ctx -> Ctx: each hook asks skipTag() for its own trait,\nand returns on the trait's skip tag or enabled = FALSE Ctx -> Ctx: a teardown asks prerequisitesMet() and returns when\none does not hold, so it never replaces a recorded failure -Ctx -> Ctx: EntityLifecycleTrait runs entityLifecycleCleanAll,\nAuthTrait authCleanUsers and authCleanRoles +Ctx -> Ctx: EntityLifecycleTrait runs entityLifecycleAfterScenario,\nAuthTrait authCleanUsers and authCleanRoles Ctx -> Backend: delete what the scenario created deactivate Ctx @enduml diff --git a/docs/architecture/dataflow-step.svg b/docs/architecture/dataflow-step.svg index dd8eb0947..ffd74b893 100644 --- a/docs/architecture/dataflow-step.svg +++ b/docs/architecture/dataflow-step.svg @@ -1 +1 @@ -Data flow: a step runsFeature fileBehatWebContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendMink sessionFeature fileBehatWebContextDrupalContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendDrupal, Drush or BlackboxMink sessionFeature fileBehatWebContextDrupalContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendDrupal, Drush or BlackboxMink sessionBeforeScenarioTestedalt[every "@behat-steps-skip:" tag names a trait]continue[one names a hook or anything else]throw RuntimeException,the run stops before any hookBeforeScenarioTestedalt[the scenario or its feature carries a "@driver:" tag]throw RuntimeException namingthe "@backend:" tag to use insteadtake the configured backends list,promote every "@backend:" tagsetScenarioBackends(order)instantiate each registered context,inject the services via BackendAwareInitializerscan the Given, When and Then step attributesattributes inheritedthrough "use" arefound toostep definitionsBeforeScenarioeach hook asks skipTag() for its own trait,and returns on the trait's skip tag or enabled = FALSEopt[the hook's trait declares prerequisites]assertPrerequisites(): hasCapability(), then thebackend already reached, else the first one listeda backend providing each capabilityeach check, e.g. moduleIsEnabled("dblog")holds or notalt[one does not hold]throw, naming the prerequisite and theswitches; the scenario fails, its steps are skippedThen the path should be "/about-us"pathAssertCurrent(path)alt[the step only needs the browser]getSession()->getCurrentUrl()current URL[the step needs Drupal]backendFor(ContentCapabilityInterface)alt[a backend in the scenario's order implements it]bootstrap, the first time onlythat backend[none does]throw UnsupportedBackendActionExceptionopt[the step's trait declares prerequisites]assertPrerequisites() for its own trait, the samechecks, run only when a scenario uses the stepthe capability call, e.g. nodeCreate(stub)resultalt[assertion holds]void[it does not]throw ExpectationException or AssertionExceptionstep passes or failsAfterScenarioeach hook asks skipTag() for its own trait,and returns on the trait's skip tag or enabled = FALSEa teardown asks prerequisitesMet() and returns whenone does not hold, so it never replaces a recorded failureEntityLifecycleTrait runs entityLifecycleCleanAll,AuthTrait authCleanUsers and authCleanRolesdelete what the scenario created \ No newline at end of file +Data flow: a step runsFeature fileBehatWebContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendMink sessionFeature fileBehatWebContextDrupalContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendDrupal, Drush or BlackboxMink sessionFeature fileBehatWebContextDrupalContextStep trait methodSkipTagListenerBackendListenerBackendRegistryBackendDrupal, Drush or BlackboxMink sessionBeforeScenarioTestedalt[every "@behat-steps-skip:" tag names a trait]continue[one names a hook or anything else]throw RuntimeException,the run stops before any hookBeforeScenarioTestedalt[the scenario or its feature carries a "@driver:" tag]throw RuntimeException namingthe "@backend:" tag to use insteadtake the configured backends list,promote every "@backend:" tagsetScenarioBackends(order)instantiate each registered context,inject the services via BackendAwareInitializerscan the Given, When and Then step attributesattributes inheritedthrough "use" arefound toostep definitionsBeforeScenarioeach hook asks skipTag() for its own trait,and returns on the trait's skip tag or enabled = FALSEopt[the hook's trait declares prerequisites]assertPrerequisites(): hasCapability(), then thebackend already reached, else the first one listeda backend providing each capabilityeach check, e.g. moduleIsEnabled("dblog")holds or notalt[one does not hold]throw, naming the prerequisite and theswitches; the scenario fails, its steps are skippedThen the path should be "/about-us"pathAssertCurrent(path)alt[the step only needs the browser]getSession()->getCurrentUrl()current URL[the step needs Drupal]backendFor(ContentCapabilityInterface)alt[a backend in the scenario's order implements it]bootstrap, the first time onlythat backend[none does]throw UnsupportedBackendActionExceptionopt[the step's trait declares prerequisites]assertPrerequisites() for its own trait, the samechecks, run only when a scenario uses the stepthe capability call, e.g. nodeCreate(stub)resultalt[assertion holds]void[it does not]throw ExpectationException or AssertionExceptionstep passes or failsAfterScenarioeach hook asks skipTag() for its own trait,and returns on the trait's skip tag or enabled = FALSEa teardown asks prerequisitesMet() and returns whenone does not hold, so it never replaces a recorded failureEntityLifecycleTrait runs entityLifecycleAfterScenario,AuthTrait authCleanUsers and authCleanRolesdelete what the scenario created \ No newline at end of file diff --git a/docs/http-clients.md b/docs/http-clients.md index 173984c38..fa0e75cc5 100644 --- a/docs/http-clients.md +++ b/docs/http-clients.md @@ -300,7 +300,7 @@ public function process(ContainerBuilder $container): void { **Register a browser adapter** that implements `HttpClientCapabilityInterface` to give another browser driver a page client: ```php -$this->getBrowserResolver()->registerAdapter(AcmeDriverAdapter::class); +$this->getBrowserCapabilityResolver()->registerAdapter(AcmeDriverAdapter::class); ``` [Capabilities of the browser driver](../MIGRATION.md#capabilities-of-the-browser-driver) covers writing the adapter itself. diff --git a/rector.php b/rector.php index 19b920343..d2ad446fb 100644 --- a/rector.php +++ b/rector.php @@ -4,12 +4,10 @@ * @file * Rector configuration. * - * Rector automatically refactors PHP code to: - * - Upgrade deprecated Drupal APIs - * - Modernize PHP syntax to leverage new language features - * - Improve code quality and maintainability + * Rector rewrites the sources to PHP 8.3 syntax and to Behat's step, hook and + * transformation attributes. It also applies the code quality, coding style, + * dead code, naming, privatization and type declaration sets. * - * @see https://github.com/palantirnet/drupal-rector * @see https://getrector.com/documentation * @see https://getrector.com/documentation/set-lists */ @@ -44,7 +42,6 @@ '/app/tests/phpunit/src', ]) ->withSkip([ - // Specific rules to skip based on project coding standards. CatchExceptionNameMatchingTypeRector::class, ChangeSwitchToMatchRector::class, InlineArrayReturnAssignRector::class, @@ -58,23 +55,19 @@ RemoveAlwaysTrueIfConditionRector::class, RenameForeachValueVariableToMatchExprVariableRector::class, // Renames a loop value after the call's return type, which names a row - // after the statement it came from and reads as camelCase where the - // coding standard wants snake_case. + // after the statement it came from. The result is camelCase where the + // coding standard requires snake_case. RenameForeachValueVariableToMatchMethodCallReturnTypeRector::class, RenameParamToMatchTypeRector::class, RenameVariableToMatchMethodCallReturnTypeRector::class, RenameVariableToMatchNewTypeRector::class, SimplifyEmptyCheckOnEmptyArrayRector::class, - // Directories to skip. '*/vendor/*', '*/node_modules/*', __DIR__ . '/tests/behat/bootstrap/BehatCliContext.php', ]) - // PHP version upgrade sets - modernizes syntax to PHP 8.3. - // Includes all rules from PHP 5.3 through 8.3. ->withPhpSets(php83: TRUE) ->withAttributesSets(behat: TRUE) - // Code quality improvement sets. ->withPreparedSets( codeQuality: TRUE, codingStyle: TRUE, @@ -83,12 +76,11 @@ privatization: TRUE, typeDeclarations: TRUE, ) - // Additional rules. ->withRules([ DeclareStrictTypesRector::class, ]) // The fixture site owns the Drupal classes the analysed code references, so - // its autoloader has to be loaded rather than only scanned: resolving a + // its autoloader has to be loaded rather than only scanned. Resolving a // parent class such as 'KernelTestBase' needs the class, not its file. ->withBootstrapFiles([ '/app/build/web/autoload.php', @@ -101,7 +93,6 @@ '/app/build/web/themes', '/app/build/web/profiles', ]) - // Drupal file extensions. ->withFileExtensions([ 'php', 'module', @@ -111,5 +102,4 @@ 'inc', 'engine', ]) - // Import configuration. ->withImportNames(importNames: FALSE, importDocBlockNames: FALSE); diff --git a/scripts/check-coverage.php b/scripts/check-coverage.php index 5eebe4c3b..9f52ae245 100644 --- a/scripts/check-coverage.php +++ b/scripts/check-coverage.php @@ -9,8 +9,9 @@ * * Where: * - TraitName: The name of the trait to check (e.g., "ElementTrait") - * - coverage_file_path: Optional path to the cobertura.xml file. - * Defaults to '/app/.logs/coverage/behat_cli/cobertura.xml'. + * - coverage_file_path: Optional path to the cobertura.xml file. Defaults to + * '.logs/coverage/behat_cli/cobertura.xml', read from '/app' when the file + * exists there and from the repository root otherwise. * * Examples: * php check-coverage.php ElementTrait diff --git a/scripts/lint-layers.php b/scripts/lint-layers.php index 4efe4ad05..bc601b40c 100644 --- a/scripts/lint-layers.php +++ b/scripts/lint-layers.php @@ -4,10 +4,12 @@ * @file * Layer dependency check. * - * Two layers are guaranteed to run without a dependency loaded, and the - * guarantee holds only while the layer references nothing from the - * namespaces it excludes. This script reads every file of each layer and - * fails on any code reference into those namespaces. + * 2 layers are guaranteed to run without a dependency loaded. The guarantee + * holds only while the layer references nothing from the namespaces it + * excludes. + * + * This script reads every file of each layer and fails on any code reference + * into those namespaces. * * Run with --path=path/to/repo to check a tree other than this repository. */ @@ -173,10 +175,11 @@ function layer_file_violations(string $file, array $forbidden_roots, array $allo * Reads the symbol a token refers to, if it refers to one. * * Qualified names cover imports, type declarations and inline references. A - * single-quoted literal shaped like a qualified name is included too, because - * a class name reached through a string skips the compiler but not the - * autoloader. Comments and docblocks are not code, so a prose mention of - * Behat is not a reference. + * single-quoted literal shaped like a qualified name is included too: the + * compiler does not resolve it, but the autoloader does. + * + * Comments and docblocks are not code, so a prose mention of Behat is not a + * reference. * * @param int $type * The token type. diff --git a/scripts/lint-traits.php b/scripts/lint-traits.php index d41e35614..abebcfb56 100644 --- a/scripts/lint-traits.php +++ b/scripts/lint-traits.php @@ -6,9 +6,10 @@ * * A trait's directory determines its kind: 'src/Steps' holds the step * vocabulary and 'src/Helper' holds the plumbing shared by step traits. Each - * is split into a 'Web' and a 'Drupal' half. This script reads both trees and - * fails when a step trait composes another step trait, or when a helper trait - * registers Gherkin. + * is split into a 'Web' and a 'Drupal' half. + * + * This script reads both trees and fails when a step trait composes another + * step trait, or when a helper trait registers Gherkin. * * A helper may register a hook: the trait that owns a teardown carries the * hook that runs it. diff --git a/scripts/merge-coverage.php b/scripts/merge-coverage.php index 26a397537..ca1a3cea2 100644 --- a/scripts/merge-coverage.php +++ b/scripts/merge-coverage.php @@ -10,8 +10,8 @@ * Where coverage_root_path is the optional path to the coverage root directory. * Defaults to '/app/.logs/coverage'. * - * This will also generate Cobertura and HTML reports from the merged coverage - * data. + * The script also generates Cobertura and HTML reports from the merged + * coverage data. */ declare(strict_types=1); @@ -20,9 +20,9 @@ use SebastianBergmann\CodeCoverage\Report\Cobertura; use SebastianBergmann\CodeCoverage\Report\Html\Facade; -// The coverage files are serialised by the fixture site's php-code-coverage, -// and only unserialise against that same installation, so the fixture's -// autoloader is preferred over the project's own. +// The fixture site's php-code-coverage serialized the coverage files, and +// they unserialize only against that installation. The fixture's autoloader +// is therefore preferred over the project's own. $autoloader = __DIR__ . '/../build/vendor/autoload.php'; if (!file_exists($autoloader)) { $autoloader = __DIR__ . '/../vendor/autoload.php'; diff --git a/scripts/provision.php b/scripts/provision.php index 1e3399802..5b3e668f8 100644 --- a/scripts/provision.php +++ b/scripts/provision.php @@ -55,8 +55,8 @@ /** * The Drupal major the fixture installs from prerelease. * - * No contrib release declares it, so the solve needs relaxing before it - * resolves, and its "drupal/core-dev" is the first to bring PHPUnit 12. + * No contrib release declares it, so the solve resolves only once relaxed. + * Its "drupal/core-dev" is also the first to require PHPUnit 12. */ const PROVISION_DRUPAL_NEXT_MAJOR = 12; @@ -82,9 +82,11 @@ * Drupal's own test namespaces, and the docroot path each maps to. * * Drupal registers them from its PHPUnit bootstrap rather than from a - * Composer entry, so a tool that loads only the autoloader cannot resolve a - * class such as KernelTestBase. The list is maintained by hand: the merge - * runs before the install, so the docroot cannot be scanned for it. + * Composer entry. A tool that loads only the autoloader therefore cannot + * resolve a class such as KernelTestBase. + * + * The list is maintained by hand: the merge runs before the install, so the + * docroot cannot be scanned for it. */ const PROVISION_DRUPAL_TEST_NAMESPACES = [ 'BuildTests', @@ -105,8 +107,8 @@ * A content type only the fixture defines. * * "drush cim" can enable the modules, abort on a fatal raised while it - * creates config entities, and still exit 0, so the import is confirmed - * against an entity that core does not ship. + * creates config entities, and still exit 0. The import is therefore + * confirmed against an entity that core does not ship. */ const PROVISION_FIXTURE_CONTENT_TYPE = 'landing_page'; @@ -198,10 +200,10 @@ function provision(): void { } if (provision_is_lenient($drupal_version)) { - // A plugin only shapes a solve that it is already installed for, and the - // fixture has no solution until this one relaxes the contrib core - // constraints. Composer loads globally installed plugins for local - // projects, so installing it outside the build breaks that circle. + // A plugin takes part in a solve only once installed, and the fixture has + // no solution until this one relaxes the contrib core constraints. + // Composer loads globally installed plugins for local projects, so a + // global install breaks the circular dependency. echo ' > Installing the Composer plugin that relaxes contrib core constraints.' . PHP_EOL; $constraint = provision_lenient_constraint($fixture_dir . '/composer.json'); provision_run('composer global config --no-interaction allow-plugins.' . PROVISION_LENIENT_PLUGIN . ' true'); @@ -259,14 +261,15 @@ function provision(): void { /** * Applies the package's own patches through Composer Patches. * - * A patch declared in composer.json is read by the Composer Patches - * "Dependencies" resolver in every project that requires this package, which - * resolves the path against that project's own root. The declaration is - * written here, over the package's own composer.json, and reverted once - * Composer has applied it. + * The Composer Patches "Dependencies" resolver reads a patch declared in + * composer.json in every project that requires this package. It resolves the + * path against that project's own root. + * + * The declaration is therefore written here, over the package's own + * composer.json, and reverted once Composer has applied it. * * The patches apply to this package's own vendor directory, not to the - * fixture site: "ahoy lint" runs the root vendor/bin/phpstan, and + * fixture site. "ahoy lint" runs the root vendor/bin/phpstan, and * mglaman/phpstan-drupal reads DRUPAL_ROOT and DRUPAL_VENDOR_ROOT only once * patched. * @@ -284,9 +287,8 @@ function provision_apply_patches(): void { echo " > Applying the package's own patches through Composer Patches." . PHP_EOL; - // The bytes restored in the finally are the bytes that were decoded, so a - // read that returned nothing cannot reach the restore and truncate the - // package's own manifest. + // The finally writes $original back, so a read that returned FALSE is + // rejected here rather than written over the package's own manifest. $composer_file = PROVISION_PACKAGE_ROOT . '/composer.json'; $original = file_get_contents($composer_file); @@ -304,9 +306,9 @@ function provision_apply_patches(): void { echo sprintf(' %s: %d patch(es)%s', $package, count($entries), PHP_EOL); } - // "install" applies a patch only while it installs the package the patch - // belongs to, and the dependencies are in place by now, so the packages - // carrying one are re-fetched and re-patched explicitly. + // "install" applies a patch only while installing the patched package, + // and the dependencies are already in place. The patched packages are + // therefore re-fetched and re-patched explicitly. $composer = 'composer --working-dir=' . PROVISION_PACKAGE_ROOT . ' --ansi --no-interaction '; provision_run($composer . 'patches-relock', ['COMPOSER_MEMORY_LIMIT' => '-1']); provision_run($composer . 'patches-repatch', ['COMPOSER_MEMORY_LIMIT' => '-1']); @@ -321,9 +323,9 @@ function provision_apply_patches(): void { /** * Maps the patch files under a directory to the packages they apply to. * - * A patch file lives at "patches///.patch", so the - * package it applies to is its own directory and the set is iterated rather - * than listed. + * A patch file sits at "patches///.patch", so its + * directory names the package it applies to. The set is iterated rather than + * listed. * * @param string $directory * Absolute path to the directory holding the patches. @@ -468,10 +470,10 @@ function provision_write_auth(string $token, string $file): void { return; } - // The file holds a credential. A umask denies every other user from the - // moment the file is created, where a chmod() after the write would leave - // the token readable in between. The build directory was emptied above, so - // the file cannot already exist with a mode of its own. + // A umask denies every other user from the moment the file is created, + // where a chmod() after the write would leave the token readable in + // between. The build directory was emptied above, so the file cannot + // already exist with a mode of its own. $umask = umask(0077); try { @@ -483,7 +485,7 @@ function provision_write_auth(string $token, string $file): void { } /** - * Writes a file, reporting a write that did not land. + * Writes a file, reporting a write that failed. * * Every write the script makes goes through here. * @@ -636,9 +638,9 @@ function provision_widen_contrib(string $directory, string $major): int { * Admits a Drupal major into an extension's core version requirement. * * Composer installs the contrib code, but Drupal reads - * core_version_requirement from each extension and refuses to enable one - * that excludes the running major. No contrib release declares Drupal 12, so - * the fixture widens what it received. + * core_version_requirement from each extension and does not enable one that + * excludes the running major. No contrib release declares Drupal 12, so the + * fixture widens what it received. * * @param string $text * The contents of an info file. @@ -669,8 +671,8 @@ function provision_widen_core_requirement(string $text, string $major): string { /** * Appends the fixture config overrides to a settings file. * - * Drupal leaves the installed settings.php read-only, so the write is opened - * and closed around. + * Drupal leaves the installed settings.php read-only, so the file is made + * writable for the write and read-only again after it. * * @param string $file * Absolute path to the settings file. @@ -769,10 +771,10 @@ function provision_write_merged_composer(string $package_file, string $fixture_f /** * Merges the package's Composer configuration into the fixture's. * - * The fixture site exercises every trait at once, so what a consumer opts - * into package by package is all required here: the package's own runtime - * requirements, the "require-dev" entries that back a "suggest" entry, and - * the packages that run the test suites. + * The fixture site exercises every trait at once, so everything a consumer + * opts into package by package is required here. This covers the package's + * own runtime requirements, the "require-dev" entries that back a "suggest" + * entry, and the packages that run the test suites. * * @param array $package * Decoded contents of the package's composer.json. @@ -808,9 +810,9 @@ function provision_merge_composer(array $package, array $fixture): array { $merged = array_replace_recursive($filtered, $fixture); - // A package named in both sections resolves to the lower of the 2 - // constraints under "--prefer-lowest", which can fall outside the range the - // fixture pins, so the fixture constraint is the one that survives. + // Under "--prefer-lowest", a package in both sections resolves to the lower + // constraint, which can fall outside the range the fixture pins. The + // fixture constraint is therefore kept. $merged['require-dev'] = array_diff_key(provision_section($merged, 'require-dev'), provision_section($merged, 'require')); return $merged; @@ -819,7 +821,7 @@ function provision_merge_composer(array $package, array $fixture): array { /** * Prefixes every PSR-4 path of an autoload section with "../". * - * The build sits one level below the package root, so a path the package + * The build sits 1 level below the package root, so a path the package * declares relative to itself only resolves from the build with the prefix. * A namespace may map to a list of directories rather than to one. * diff --git a/src/Backend/Alias/CreationAliasInterface.php b/src/Backend/Alias/CreationAliasInterface.php index ecd6a241c..eaa8953d4 100644 --- a/src/Backend/Alias/CreationAliasInterface.php +++ b/src/Backend/Alias/CreationAliasInterface.php @@ -7,7 +7,7 @@ /** * Base contract for a creation alias. * - * An alias represents one ergonomic stub property that is not a real + * An alias represents 1 ergonomic stub property that is not a real * Drupal field, together with the resolution behaviour the backend * applies during entity creation. Concrete aliases implement either * 'PreCreateAliasInterface' (to mutate the stub before save) or @@ -34,11 +34,11 @@ public function getEntityType(): string; /** * Returns a human-readable description of what this alias does. * - * Should describe the input shape, the resolution behaviour, and the - * resulting effect on the created entity in a single sentence. + * Covers the input shape, the resolution behaviour and the resulting effect + * on the created entity in a few short sentences. * * @return string - * A single-sentence description of the alias's behaviour. + * A short prose description of the alias's behaviour. */ public function getDescription(): string; diff --git a/src/Backend/Alias/CreationAliasRegistryTrait.php b/src/Backend/Alias/CreationAliasRegistryTrait.php index c90685437..39812e513 100644 --- a/src/Backend/Alias/CreationAliasRegistryTrait.php +++ b/src/Backend/Alias/CreationAliasRegistryTrait.php @@ -10,12 +10,12 @@ * Implements the 'CreationAliasCapabilityInterface' registry on a backend. * * Hosts an entity-type-indexed map of registered aliases, exposes the - * 'getCreationAliases()' lookup, and provides two protected dispatcher - * helpers for create methods to invoke: 'applyPreCreateAliases()' before - * 'Entity::create()' and 'applyPostCreateAliases()' after save. + * 'getCreationAliases()' lookup, and provides 2 protected dispatchers for + * create methods: 'applyPreCreateAliases()' before 'Entity::create()' and + * 'applyPostCreateAliases()' after save. * - * Both dispatchers gate on 'EntityStubInterface::hasValue()' so an alias - * only fires when the stub actually carries the key it owns. + * Both dispatchers gate on 'EntityStubInterface::hasValue()', so an alias + * runs only when the stub carries the key it owns. */ trait CreationAliasRegistryTrait { @@ -30,8 +30,8 @@ trait CreationAliasRegistryTrait { * Adds an alias to the registry. * * Re-registering the same name on the same entity type replaces the - * previous entry so subclasses can override aliases by simply - * registering a new instance after 'parent::' setup. + * previous entry, so a subclass overrides an alias by registering a new + * instance after 'parent::' setup. * * @param \DrevOps\BehatSteps\Backend\Alias\CreationAliasInterface $alias * The alias to register. diff --git a/src/Backend/Alias/PostCreateAliasInterface.php b/src/Backend/Alias/PostCreateAliasInterface.php index 1a018e640..41650431e 100644 --- a/src/Backend/Alias/PostCreateAliasInterface.php +++ b/src/Backend/Alias/PostCreateAliasInterface.php @@ -9,7 +9,7 @@ /** * A creation alias that acts on the entity after it has been saved. * - * Use this lifecycle for side-effects that require the entity to exist + * This lifecycle is for side-effects that require the entity to exist * first - for example, assigning roles to a user after the user record * has been written, or attaching references to an entity that needs an * id before it can be linked. diff --git a/src/Backend/Alias/PreCreateAliasInterface.php b/src/Backend/Alias/PreCreateAliasInterface.php index 49de057f3..2e3b33925 100644 --- a/src/Backend/Alias/PreCreateAliasInterface.php +++ b/src/Backend/Alias/PreCreateAliasInterface.php @@ -11,7 +11,7 @@ * * Implementations resolve the alias value, write any derived real-field * values back onto the stub, and remove the alias's own key from the - * stub so the values bag passed to Drupal's entity factory contains + * stub. The values bag passed to Drupal's entity factory then contains * only real fields. */ interface PreCreateAliasInterface extends CreationAliasInterface { @@ -19,10 +19,10 @@ interface PreCreateAliasInterface extends CreationAliasInterface { /** * Resolves the alias value and mutates the stub in place. * - * Implementations MUST remove the alias's own value from the stub - * (via 'EntityStubInterface::removeValue()') once resolution succeeds, - * unless they intentionally overwrite the same key with the resolved - * representation (e.g. swapping a term name for a tid). + * Implementations MUST remove the alias's own value from the stub via + * 'EntityStubInterface::removeValue()' once resolution succeeds. The + * exception is an alias that deliberately overwrites the same key with + * the resolved representation, such as a term name swapped for a tid. * * @param \DrevOps\BehatSteps\Backend\Entity\EntityStubInterface $stub * The stub being prepared for creation. Must already carry a value diff --git a/src/Backend/Alias/RolesAlias.php b/src/Backend/Alias/RolesAlias.php index 3387e0a62..1541ce61f 100644 --- a/src/Backend/Alias/RolesAlias.php +++ b/src/Backend/Alias/RolesAlias.php @@ -60,8 +60,8 @@ public function applyAfterCreate(EntityStubInterface $stub, object $entity): voi foreach ($roles as $role) { // EntityReferenceHandler expands 'roles' into records like - // '['target_id' => 'editor']'. Unwrap that here so the alias can - // operate on the same role name the caller supplied originally. + // ['target_id' => 'editor'], so the record is unwrapped back to the + // role name the caller supplied. if (is_array($role) && array_key_exists('target_id', $role)) { $role = $role['target_id']; } diff --git a/src/Backend/BlackboxBackendInterface.php b/src/Backend/BlackboxBackendInterface.php index 1d15890c6..915cb8921 100644 --- a/src/Backend/BlackboxBackendInterface.php +++ b/src/Backend/BlackboxBackendInterface.php @@ -9,8 +9,9 @@ * * Performs no backend operations. Implementations satisfy only the base * backend contract and MUST NOT implement any interface in the - * 'DrevOps\BehatSteps\Backend\Capability' namespace, so 'instanceof - * BlackboxBackendInterface' is a reliable negative-capability guarantee. + * 'DrevOps\BehatSteps\Backend\Capability' namespace. An 'instanceof + * BlackboxBackendInterface' check is therefore a reliable + * negative-capability guarantee. */ interface BlackboxBackendInterface extends BackendInterface { diff --git a/src/Backend/Capability/BlockCapabilityInterface.php b/src/Backend/Capability/BlockCapabilityInterface.php index 5e18a0296..648981b75 100644 --- a/src/Backend/Capability/BlockCapabilityInterface.php +++ b/src/Backend/Capability/BlockCapabilityInterface.php @@ -9,7 +9,7 @@ /** * Capability: place blocks and create content blocks. * - * Groups the two distinct block operations a backend typically needs during + * Groups the 2 distinct block operations a backend typically needs during * scenario setup: * * - Placing a block in a region - the 'block' config entity. diff --git a/src/Backend/Capability/ConfigCapabilityInterface.php b/src/Backend/Capability/ConfigCapabilityInterface.php index 702087f78..17d02d57a 100644 --- a/src/Backend/Capability/ConfigCapabilityInterface.php +++ b/src/Backend/Capability/ConfigCapabilityInterface.php @@ -23,7 +23,10 @@ interface ConfigCapabilityInterface { public function configGet(string $name, string $key = ''): mixed; /** - * Returns the original (on-disk) configuration value. + * Returns a configuration value with overrides not applied. + * + * Module and 'settings.php' overrides are left out, so the result is the + * stored value a write replaces. * * @param string $name * The configuration object name. @@ -31,7 +34,7 @@ public function configGet(string $name, string $key = ''): mixed; * The key within the configuration object. Empty for the whole object. * * @return mixed - * The original configuration value, or NULL if not set. + * The stored configuration value, or NULL if not set. */ public function configGetOriginal(string $name, string $key = ''): mixed; @@ -73,7 +76,7 @@ public function configGetData(string $name): array; * Replaces every stored value of a configuration object. * * Keys absent from the given data are dropped, so restoring a snapshot - * removes the keys a scenario added. + * removes the keys added since it was taken. * * @param string $name * The configuration object name. diff --git a/src/Backend/Capability/CoreCapabilityInterface.php b/src/Backend/Capability/CoreCapabilityInterface.php index 2d96174ed..507ea0f79 100644 --- a/src/Backend/Capability/CoreCapabilityInterface.php +++ b/src/Backend/Capability/CoreCapabilityInterface.php @@ -11,7 +11,7 @@ * * A backend providing this capability has Drupal bootstrapped once it is * resolved, so '\Drupal::' statics and the entity API are reachable. A step - * that calls into Drupal directly asks for this capability rather than for a + * that calls into Drupal directly requires this capability instead of a * backend class. */ interface CoreCapabilityInterface { diff --git a/src/Backend/Capability/CreationAliasCapabilityInterface.php b/src/Backend/Capability/CreationAliasCapabilityInterface.php index f0b19da7f..9602e38a8 100644 --- a/src/Backend/Capability/CreationAliasCapabilityInterface.php +++ b/src/Backend/Capability/CreationAliasCapabilityInterface.php @@ -5,12 +5,13 @@ namespace DrevOps\BehatSteps\Backend\Capability; /** - * Capability: expose the entity creation aliases the backend understands. + * Capability: expose the backend's entity creation aliases. * - * Creation aliases are ergonomic property names on entity stubs that - * are not real Drupal fields - for example, 'author' on a node stub - * that the backend translates to a 'uid' value during creation. This - * capability lets consumers discover which aliases are accepted, for + * Creation aliases are convenience property names on entity stubs that are + * not real Drupal fields. An example is 'author' on a node stub, which the + * backend translates to a 'uid' value during creation. + * + * This capability lets consumers discover which aliases are accepted, for * what entity type, and how to document them. * * This interface is intentionally NOT extended by the composite backend diff --git a/src/Backend/Capability/DrushCapabilityInterface.php b/src/Backend/Capability/DrushCapabilityInterface.php index 45ac02a73..1debbaf58 100644 --- a/src/Backend/Capability/DrushCapabilityInterface.php +++ b/src/Backend/Capability/DrushCapabilityInterface.php @@ -22,7 +22,7 @@ interface DrushCapabilityInterface { * Options to pass to Drush. * * @return string - * The command's stdout, or its stderr when stdout is empty. + * The command's stdout, or its stderr when stdout is empty or '0'. * * @throws \RuntimeException * When the command exits with a non-zero status. diff --git a/src/Backend/Capability/ModuleCapabilityInterface.php b/src/Backend/Capability/ModuleCapabilityInterface.php index a38f8af06..794c5988d 100644 --- a/src/Backend/Capability/ModuleCapabilityInterface.php +++ b/src/Backend/Capability/ModuleCapabilityInterface.php @@ -36,8 +36,8 @@ public function moduleIsEnabled(string $module_name): bool; /** * Whether a module's code is present, installed or not. * - * Distinguishes a module that is merely disabled from one the site cannot - * install because its code is absent. + * Distinguishes a disabled module from one the site cannot install because + * its code is absent. * * @param string $module_name * The module machine name. diff --git a/src/Backend/Capability/StateCapabilityInterface.php b/src/Backend/Capability/StateCapabilityInterface.php index 2a314b8a7..59ac15114 100644 --- a/src/Backend/Capability/StateCapabilityInterface.php +++ b/src/Backend/Capability/StateCapabilityInterface.php @@ -41,9 +41,9 @@ public function stateDelete(string $name): void; /** * Whether a state key is set. * - * A key holding NULL counts as set where the backend can tell the difference. - * A backend reading state through 'stateGet()' alone cannot, and says so on - * its own implementation. + * A key holding NULL counts as set where the backend can distinguish it + * from an absent key. A backend reading state through 'stateGet()' alone + * cannot, and documents this on its own implementation. * * @param string $name * The state key. diff --git a/src/Backend/Core/Alias/AuthorAlias.php b/src/Backend/Core/Alias/AuthorAlias.php index 4b8e21451..cb4c8d783 100644 --- a/src/Backend/Core/Alias/AuthorAlias.php +++ b/src/Backend/Core/Alias/AuthorAlias.php @@ -82,17 +82,17 @@ public function applyToStub(EntityStubInterface $stub): void { $user = ($this->userLookup)($name); if ($user === NULL) { - throw new CreationAliasResolutionException(sprintf("Cannot create node because user '%s', referenced via the 'author' creation alias, does not exist.", $name)); + throw new CreationAliasResolutionException(sprintf("Cannot create node because user \"%s\", referenced via the 'author' creation alias, does not exist.", $name)); } if (!method_exists($user, 'id')) { - throw new CreationAliasResolutionException(sprintf("Cannot create node because the 'author' lookup returned an object without an 'id()' method while resolving '%s'.", $name)); + throw new CreationAliasResolutionException(sprintf("Cannot create node because the 'author' lookup returned an object without an 'id()' method while resolving \"%s\".", $name)); } $resolved_uid = $user->id(); if (!is_numeric($resolved_uid) || (int) $resolved_uid <= 0) { - throw new CreationAliasResolutionException(sprintf("Cannot create node because the user resolved from 'author' = '%s' has an invalid id.", $name)); + throw new CreationAliasResolutionException(sprintf("Cannot create node because the user resolved from 'author' = \"%s\" has an invalid id.", $name)); } // The downstream entity-reference handler treats an integer as a diff --git a/src/Backend/Core/Alias/ParentTermAlias.php b/src/Backend/Core/Alias/ParentTermAlias.php index 66005080c..d51dad6aa 100644 --- a/src/Backend/Core/Alias/ParentTermAlias.php +++ b/src/Backend/Core/Alias/ParentTermAlias.php @@ -53,7 +53,7 @@ public function __construct(?\Closure $parent_lookup = NULL) { } if (count($tids) > 1) { - throw new CreationAliasResolutionException(sprintf("Cannot resolve parent term '%s' in vocabulary '%s' because multiple terms share that name.", $parent_name, $vid)); + throw new CreationAliasResolutionException(sprintf('Cannot resolve parent term "%s" in vocabulary "%s" because multiple terms share that name.', $parent_name, $vid)); } return reset($tids); @@ -105,17 +105,17 @@ public function applyToStub(EntityStubInterface $stub): void { $parent_name = (string) $parent_name; if ($vid === '') { - throw new CreationAliasResolutionException(sprintf("Cannot resolve parent term '%s' because the stub has no vocabulary.", $parent_name)); + throw new CreationAliasResolutionException(sprintf('Cannot resolve parent term "%s" because the stub has no vocabulary.', $parent_name)); } $tid = ($this->parentLookup)($parent_name, $vid); if ($tid === NULL) { - throw new CreationAliasResolutionException(sprintf("Cannot create term because parent term '%s' does not exist in vocabulary '%s'.", $parent_name, $vid)); + throw new CreationAliasResolutionException(sprintf('Cannot create term because parent term "%s" does not exist in vocabulary "%s".', $parent_name, $vid)); } if (!is_numeric($tid) || (int) $tid <= 0) { - throw new CreationAliasResolutionException(sprintf("Cannot resolve parent term '%s' in vocabulary '%s' because the lookup returned an invalid tid.", $parent_name, $vid)); + throw new CreationAliasResolutionException(sprintf('Cannot resolve parent term "%s" in vocabulary "%s" because the lookup returned an invalid tid.', $parent_name, $vid)); } // The downstream entity-reference handler treats an integer as a diff --git a/src/Backend/Core/Alias/VocabularyMachineNameAlias.php b/src/Backend/Core/Alias/VocabularyMachineNameAlias.php index 9e0ee5c4d..fdfa20e21 100644 --- a/src/Backend/Core/Alias/VocabularyMachineNameAlias.php +++ b/src/Backend/Core/Alias/VocabularyMachineNameAlias.php @@ -12,9 +12,10 @@ * * The typed bundle constructor argument and an explicit 'vid' value * both take priority over this alias. When neither is present the - * alias value is copied to 'vid'. The alias key is always removed once - * handled. The alias does not validate vocabulary existence - the - * create method does that after all pre-create aliases have run. + * alias value is copied to 'vid'. + * + * The alias key is always removed once handled. The alias does not + * validate vocabulary existence. */ class VocabularyMachineNameAlias implements PreCreateAliasInterface { diff --git a/src/Backend/Core/Core.php b/src/Backend/Core/Core.php index 37e5599df..0c74afc91 100644 --- a/src/Backend/Core/Core.php +++ b/src/Backend/Core/Core.php @@ -70,11 +70,6 @@ class Core implements CoreInterface, AuthenticationCapabilityInterface, Creation /** * Registered field handler classes, keyed by field type id. * - * Populated at construction with the project's built-in handlers and - * extended at runtime via 'registerFieldHandler()'. Lookup in - * 'getFieldHandler()' consults this map first, falling back to - * 'DefaultHandler' when a field type has no registered class. - * * @var array> */ protected array $fieldHandlers = []; @@ -102,7 +97,7 @@ class Core implements CoreInterface, AuthenticationCapabilityInterface, Creation * @param string $drupal_root * The absolute path to the Drupal root directory. * @param string $uri - * URI that is accessing Drupal. Defaults to 'default'. + * The URI used to access Drupal. Defaults to 'default'. * @param \Drupal\Component\Utility\Random|null $random * Optional random-value generator. */ @@ -339,12 +334,11 @@ protected function expandEntityFields(EntityStubInterface $stub): void { $entity_type = $stub->getEntityType(); $definition = $this->loadEntityTypeDefinition($entity_type); - // The id key and bundle key identify the record itself and must not pass - // through the handler pipeline. On 'commerce_product' the bundle key - // 'type' is also a base entity_reference field. Expanding it would - // resolve the bundle machine name through EntityReferenceHandler and - // overwrite the scalar with ['target_id' => ...], corrupting every - // subsequent bundle lookup for the same stub. + // The id key and bundle key identify the record, so neither enters the + // handler pipeline. On 'commerce_product' the bundle key 'type' is also + // a base entity_reference field, and EntityReferenceHandler would + // replace the scalar with ['target_id' => ...], corrupting the stub's + // later bundle lookups. $skip = array_filter([$definition->getKey('id'), $definition->getKey('bundle')]); $bundle = $this->resolveBundle($stub); @@ -368,9 +362,9 @@ protected function expandEntityFields(EntityStubInterface $stub): void { /** * Resolves the bundle for an entity stub. * - * Consults the entity type's bundle key in the values bag first, then the - * typed 'bundle' constructor argument, then falls back to the entity type - * id (single-bundle entities like 'user' use the type id as their bundle). + * Reads the entity type's bundle key from the values bag first, then the + * typed 'bundle' constructor argument, then the entity type id. + * Single-bundle entities like 'user' use the type id as their bundle. * * @param \DrevOps\BehatSteps\Backend\Entity\EntityStubInterface $stub * The stub. @@ -748,7 +742,7 @@ public function validateDrupalSite(): void { $drupal_base_url = parse_url($this->uri); if ($drupal_base_url === FALSE) { - throw new BootstrapException(sprintf('Cannot parse the site URI %s', $this->uri)); + throw new BootstrapException(sprintf('Cannot parse the site URI %s.', $this->uri)); } $drupal_base_url += [ @@ -780,7 +774,7 @@ public function validateDrupalSite(): void { $conf_path = DrupalKernel::findSitePath(Request::createFromGlobals()); $conf_file = $this->drupalRoot . sprintf('/%s/settings.php', $conf_path); if (!file_exists($conf_file)) { - throw new BootstrapException(sprintf('Could not find a Drupal settings.php file at "%s"', $conf_file)); + throw new BootstrapException(sprintf('Could not find a Drupal settings.php file at "%s".', $conf_file)); } $drushrc_file = $this->drupalRoot . sprintf('/%s/drushrc.php', $conf_path); if (file_exists($drushrc_file)) { @@ -801,7 +795,7 @@ public function termCreate(EntityStubInterface $stub): EntityStubInterface { } if (Vocabulary::load($vocabulary) === NULL) { - throw new \RuntimeException(sprintf("Cannot create term because vocabulary '%s' does not exist.", $vocabulary)); + throw new \RuntimeException(sprintf('Cannot create term because vocabulary "%s" does not exist.', $vocabulary)); } $stub->setValue('vid', $vocabulary); @@ -1099,7 +1093,7 @@ public function stateDelete(string $name): void { */ public function stateExists(string $name): bool { // The state service reads a stored NULL and an absent key alike, so the - // backing key-value store answers this instead. + // backing key-value store is queried instead. return \Drupal::keyValue('state')->has($name); } @@ -1118,7 +1112,7 @@ public function entityCreate(EntityStubInterface $stub): EntityStubInterface { $id_key = $definition->getKey('id'); if (!is_string($id_key)) { - throw new \RuntimeException(sprintf("Cannot create an entity of type '%s' because it declares no id key.", $entity_type)); + throw new \RuntimeException(sprintf('Cannot create an entity of type "%s" because it declares no id key.', $entity_type)); } // storage->create() reads the bundle under the entity type's own bundle @@ -1133,7 +1127,7 @@ public function entityCreate(EntityStubInterface $stub): EntityStubInterface { $bundles = $bundle_info->getBundleInfo($entity_type); if (!array_key_exists((string) $stub->getValue($bundle_key), $bundles)) { - throw new \RuntimeException(sprintf("Cannot create entity because provided bundle '%s' does not exist.", $stub->getValue($bundle_key))); + throw new \RuntimeException(sprintf('Cannot create entity because provided bundle "%s" does not exist.', $stub->getValue($bundle_key))); } } diff --git a/src/Backend/Core/CoreInterface.php b/src/Backend/Core/CoreInterface.php index 20dc50317..1727d4b0c 100644 --- a/src/Backend/Core/CoreInterface.php +++ b/src/Backend/Core/CoreInterface.php @@ -29,8 +29,8 @@ * handler, and so on) with the operational capabilities every Core provides. * * Authentication is deliberately absent: a Core declares - * 'AuthenticationCapabilityInterface' separately, so 'instanceof' answers - * whether it can log a user in. + * 'AuthenticationCapabilityInterface' separately, so an 'instanceof' check + * determines whether it can log a user in. */ interface CoreInterface extends BatchCapabilityInterface, @@ -101,11 +101,11 @@ public function getFieldHandler(EntityStubInterface $stub, string $entity_type, * Registers a field handler class for a field type. * * Overrides one of the backend's built-in handlers or adds a handler for a - * field type the backend does not ship one for. The registration wins over - * the defaults registered by 'Core::registerDefaultFieldHandlers()' in the - * constructor. Handlers must implement 'FieldHandlerInterface'; a class - * that does not triggers a 'RuntimeException' at registration - * time rather than at field resolution time. + * field type the backend does not ship one for. The registration replaces + * the default registered by 'Core::registerDefaultFieldHandlers()' in the + * constructor. A class that does not implement 'FieldHandlerInterface' + * triggers a 'RuntimeException' at registration time rather than at field + * resolution time. * * @param string $field_type * The Drupal field type id, e.g. 'boolean', 'entity_reference', or a diff --git a/src/Backend/Core/Field/AbstractHandler.php b/src/Backend/Core/Field/AbstractHandler.php index d0888f1f0..b51f038ee 100644 --- a/src/Backend/Core/Field/AbstractHandler.php +++ b/src/Backend/Core/Field/AbstractHandler.php @@ -54,9 +54,8 @@ public function __construct(EntityStubInterface $stub, string $entity_type, stri $entity_field_manager = \Drupal::service('entity_field.manager'); $storage_definitions = $entity_field_manager->getFieldStorageDefinitions($entity_type); - // Bundle precedence is bundle key value, then typed bundle, then entity - // type (single-bundle entities like 'user' use the entity type as the - // bundle). + // A single-bundle entity type such as 'user' uses the entity type as its + // bundle, so the entity type is the fallback. $bundle_key = \Drupal::entityTypeManager()->getDefinition($entity_type)->getKey('bundle'); $bundle = $entity_type; @@ -91,7 +90,7 @@ final public function expand(mixed $values): array { * Recognised input shapes: * - Bare scalar -> wrapped as a single record using the main property. * - List of scalars -> each wrapped as a record. - * - Single keyed record -> wrapped in a one-element list. + * - Single keyed record -> wrapped in a 1-element list. * - List of records -> returned unchanged. * - Mixed list of scalars and records -> scalars wrapped, records kept. * @@ -119,8 +118,8 @@ protected function normalize(mixed $values): array { } // '['foo.jpg', 'alt' => 'A']' is ambiguous: 'foo.jpg' could be the main - // value with 'alt' as an extra, or two separate deltas with one of them - // named. The mixed shape is rejected. + // value with 'alt' as an extra, or 2 separate deltas with 1 of them + // named. $has_int_key = FALSE; $has_string_key = FALSE; @@ -152,9 +151,8 @@ protected function normalize(mixed $values): array { } // A record without the main property is almost always a caller mistake: - // the path, value or uri is missing and only extras like 'alt' or - // 'format' remain. Rejecting it here stops a handler dispatching on - // missing data. + // only extras like 'alt' or 'format' remain. Rejecting it here stops a + // handler dispatching on missing data. foreach ($records as $record) { if (!array_key_exists($this->mainProperty, $record)) { throw new \RuntimeException(sprintf( diff --git a/src/Backend/Core/Field/DateRecurHandler.php b/src/Backend/Core/Field/DateRecurHandler.php index 23957bbaf..343c6e2d4 100644 --- a/src/Backend/Core/Field/DateRecurHandler.php +++ b/src/Backend/Core/Field/DateRecurHandler.php @@ -9,10 +9,13 @@ * * The base normalize() folds the bare-scalar 'value' shorthand and keyed * multi-column records ('value', 'end_value', 'rrule', 'timezone', - * 'infinite'). The 'value' and 'end_value' columns are stored verbatim and - * interpreted in the record's own 'timezone' (not UTC), and the field type's - * preSave() derives 'infinite' from the rrule. The handler therefore relays - * the multi-column records through unchanged. + * 'infinite'). + * + * The 'value' and 'end_value' columns are stored verbatim and interpreted in + * the record's own 'timezone' (not UTC). The field type's preSave() derives + * 'infinite' from the rrule. + * + * The handler therefore relays the multi-column records unchanged. * * @see https://www.drupal.org/project/date_recur */ diff --git a/src/Backend/Core/Field/DefaultHandler.php b/src/Backend/Core/Field/DefaultHandler.php index b5578605a..2b0eb6c10 100644 --- a/src/Backend/Core/Field/DefaultHandler.php +++ b/src/Backend/Core/Field/DefaultHandler.php @@ -7,10 +7,10 @@ /** * Fallback handler for field types with no dedicated handler. * - * Relays the normalised records to storage verbatim. A field this handler - * cannot marshal - an entity-reference target or a complex/nested value - is - * rejected during handler selection (see 'FieldShapeClassifierInterface'), - * so the field is a plain-scalar shape by the time this handler runs. + * Relays the normalised records to storage verbatim. An entity-reference + * target or a complex/nested value is rejected during handler selection (see + * 'FieldShapeClassifierInterface'), so every field this handler receives is + * a plain-scalar shape. * * See 'src/Backend/Core/Field/README.md' for the full handler-selection * table. diff --git a/src/Backend/Core/Field/EntityReferenceHandler.php b/src/Backend/Core/Field/EntityReferenceHandler.php index 54f6ae9e2..5489ab7d0 100644 --- a/src/Backend/Core/Field/EntityReferenceHandler.php +++ b/src/Backend/Core/Field/EntityReferenceHandler.php @@ -23,9 +23,8 @@ protected function doExpand(array $records): array { $lookup = $record[$this->mainProperty]; - // Already-resolved integer ids (caller-supplied or alias-resolved) - // bypass the entity-storage round-trip; only string labels still - // need a lookup. + // An integer id is already resolved; only a string lookup requires an + // entity query. if (is_int($lookup)) { $resolved[] = $record; continue; @@ -53,7 +52,7 @@ protected function getReferenceTarget(): ReferenceTarget { $id_key = $definition->getKey('id'); if (!is_string($id_key)) { - throw new \RuntimeException(sprintf("Cannot resolve a reference to '%s' because it declares no id key.", $entity_type_id)); + throw new \RuntimeException(sprintf('Cannot resolve a reference to "%s" because it declares no id key.', $entity_type_id)); } // User entities return FALSE for getKey('label'), so 'name' is used @@ -72,7 +71,7 @@ protected function getReferenceTarget(): ReferenceTarget { * Resolves a lookup value to the id of an entity the field may target. * * @param mixed $lookup - * An entity label, or an entity id Drupal serialised as a string. + * An entity label, or an entity id Drupal serialized as a string. * @param \DrevOps\BehatSteps\Backend\Core\Field\ReferenceTarget $target * The entity-type facts to resolve against. * @@ -87,10 +86,9 @@ protected function resolveTargetId(mixed $lookup, ReferenceTarget $target): int| $query->accessCheck(FALSE); if ($target->labelKey) { - // A numeric-string lookup is ambiguous: the caller may be passing an - // entity id that Drupal serialised as a string, or a label that - // happens to be digits. An OR-group matches either side, and the - // entity layer's first hit wins. + // A numeric-string lookup is ambiguous: an entity id Drupal serialized + // as a string, or a label made of digits. An OR-group matches either, + // and the first match is returned. $is_numeric_id = is_string($lookup) && ctype_digit($lookup); $or = $query->orConditionGroup(); @@ -112,7 +110,7 @@ protected function resolveTargetId(mixed $lookup, ReferenceTarget $target): int| $entities = $query->execute(); if (!$entities) { - throw new \RuntimeException(sprintf("No entity '%s' of type '%s' exists.", $lookup, $target->entityTypeId)); + throw new \RuntimeException(sprintf('No entity "%s" of type "%s" exists.', $lookup, $target->entityTypeId)); } return array_shift($entities); diff --git a/src/Backend/Core/Field/EntityReferenceRevisionsHandler.php b/src/Backend/Core/Field/EntityReferenceRevisionsHandler.php index 94bad1463..f8f199046 100644 --- a/src/Backend/Core/Field/EntityReferenceRevisionsHandler.php +++ b/src/Backend/Core/Field/EntityReferenceRevisionsHandler.php @@ -10,8 +10,8 @@ /** * Field handler for 'entity_reference_revisions' fields (Paragraphs et al). * - * A revision reference resolves its target the same way a plain entity - * reference does, then records the target's revision id beside the id. + * The handler resolves the target as 'EntityReferenceHandler' does, then + * records the target's revision id beside the id. */ class EntityReferenceRevisionsHandler extends EntityReferenceHandler { @@ -34,13 +34,13 @@ protected function doExpand(array $records): array { $target = $storage->load($resolved_id); if ($target === NULL) { - throw new \RuntimeException(sprintf("Entity '%s' of type '%s' no longer exists.", $resolved_id, $target_facts->entityTypeId)); + throw new \RuntimeException(sprintf('Entity "%s" of type "%s" no longer exists.', $resolved_id, $target_facts->entityTypeId)); } // 'resolveTargetId()' filters by bundle, but an integer lookup bypasses // it and loads directly, so the loaded target is checked here. if ($target_facts->bundles && $target instanceof EntityInterface && !in_array($target->bundle(), $target_facts->bundles, TRUE)) { - throw new \RuntimeException(sprintf("Entity '%s' of type '%s' is of bundle '%s', which the field does not accept. Allowed: %s.", $resolved_id, $target_facts->entityTypeId, $target->bundle(), implode(', ', $target_facts->bundles))); + throw new \RuntimeException(sprintf('Entity "%s" of type "%s" is of bundle "%s", which the field does not accept. Allowed: %s.', $resolved_id, $target_facts->entityTypeId, $target->bundle(), implode(', ', $target_facts->bundles))); } $record[$this->mainProperty] = $resolved_id; diff --git a/src/Backend/Core/Field/FieldClassifierInterface.php b/src/Backend/Core/Field/FieldClassifierInterface.php index 5ef95b304..e735242e7 100644 --- a/src/Backend/Core/Field/FieldClassifierInterface.php +++ b/src/Backend/Core/Field/FieldClassifierInterface.php @@ -5,9 +5,9 @@ namespace DrevOps\BehatSteps\Backend\Core\Field; /** - * Classifies Drupal fields into the nine mutually exclusive F-row categories. + * Classifies Drupal fields into the 9 mutually exclusive F-row categories. * - * Each predicate answers "is this field in F{N}?" for one row of the truth + * Each predicate answers "is this field in F{N}?" for 1 row of the truth * table, based only on the field's declaration and storage profile. The * classifier does not decide what is done with a classification; that * decision belongs to the consumer. diff --git a/src/Backend/Core/Field/FieldShapeClassifier.php b/src/Backend/Core/Field/FieldShapeClassifier.php index 5564ef6e4..7c3571489 100644 --- a/src/Backend/Core/Field/FieldShapeClassifier.php +++ b/src/Backend/Core/Field/FieldShapeClassifier.php @@ -46,8 +46,8 @@ public function fieldIsComplexValue(FieldStorageDefinitionInterface $storage): b /** * Yields a field's stored (non-computed) property definitions. * - * Computed properties are storage-derived, not author-supplied, so they never - * bear on whether the caller can express the field as a plain scalar. + * Computed properties are storage-derived, not author-supplied, so they do + * not affect the field's value shape. * * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage * The field storage definition to inspect. diff --git a/src/Backend/Core/Field/FieldShapeClassifierInterface.php b/src/Backend/Core/Field/FieldShapeClassifierInterface.php index c6e891c2b..1d48c81c3 100644 --- a/src/Backend/Core/Field/FieldShapeClassifierInterface.php +++ b/src/Backend/Core/Field/FieldShapeClassifierInterface.php @@ -11,15 +11,16 @@ * * 'FieldClassifierInterface' answers the pipeline-entry (F-row) question from * a field's origin and storage profile. This interface answers the orthogonal - * value-shape question the README calls a "handler-selection input": whether - * a field's stored value is a plain scalar the default handler can relay, or - * a shape that needs a dedicated handler. + * value-shape question the README calls a "handler-selection input". * - * Both predicates read only the storage definition's stored (non-computed) - * property definitions and enumerate no field-type or data-type strings. A - * datetime, boolean, or list column is therefore neither an entity reference - * nor complex: it is a plain scalar the default relays, and value translation - * for it belongs in a dedicated handler. + * A stored value is either a plain scalar the default handler can relay or a + * shape that requires a dedicated handler. Both predicates read only the + * storage definition's stored (non-computed) property definitions and + * enumerate no field-type or data-type strings. + * + * A datetime, boolean, or list column is therefore neither an entity + * reference nor complex: it is a plain scalar the default relays. Value + * translation for such a column belongs in a dedicated handler. * * See 'src/Backend/Core/Field/README.md' for the value-shape axis and how * 'Core' consumes it during handler selection. @@ -33,9 +34,7 @@ interface FieldShapeClassifierInterface { * The field storage definition to inspect. * * @return bool - * TRUE when a non-computed property is a 'DataReferenceTargetDefinition' - - * the caller supplies a label, path, or name a dedicated handler must - * resolve to an id the author cannot know. + * TRUE when a non-computed property is a 'DataReferenceTargetDefinition'. */ public function fieldIsEntityReference(FieldStorageDefinitionInterface $storage): bool; @@ -47,8 +46,7 @@ public function fieldIsEntityReference(FieldStorageDefinitionInterface $storage) * * @return bool * TRUE when a non-computed property is a 'ComplexDataDefinitionInterface' - * (e.g. a map) or a 'ListDataDefinitionInterface' - there is no single - * scalar shape for the default handler to relay. + * (e.g. a map) or a 'ListDataDefinitionInterface'. */ public function fieldIsComplexValue(FieldStorageDefinitionInterface $storage): bool; diff --git a/src/Backend/Core/Field/FileHandler.php b/src/Backend/Core/Field/FileHandler.php index b06bbe311..e01a62d64 100644 --- a/src/Backend/Core/Field/FileHandler.php +++ b/src/Backend/Core/Field/FileHandler.php @@ -49,9 +49,9 @@ protected function doExpand(array $records): array { /** * Reads the id from a saved file entity. * - * The parameter is typed 'object' so a unit-test double can stand in - * without implementing Drupal's File entity contract. The 'id()' call is - * therefore unchecked until here. + * The parameter is typed 'object' so a unit-test double need not implement + * Drupal's File entity contract. The 'method_exists()' guard is the only + * check on 'id()'. * * @param object $file * A File entity, or a File-compatible stub in tests. @@ -80,12 +80,11 @@ protected function getFieldLabel(): string { * A managed file that already exists can be referenced by its URI * ('public://foo.txt') or its bare basename ('foo.txt') without * triggering a re-upload. Paths containing '/' but no scheme (e.g. - * '/tmp/foo.txt') are treated as disk paths and fall through to the - * upload path unchanged. + * '/tmp/foo.txt') are disk paths and return NULL. * * The native return type is 'object' (not FileInterface) so a unit-test - * double that exposes only 'id()' can pass without implementing the full - * File entity contract. In production the storage returns File entities. + * double that exposes only 'id()' need not implement the full File entity + * contract. In production the storage returns File entities. * * @param string $value * The raw field value: URI, bare basename, or absolute filesystem path. diff --git a/src/Backend/Core/Field/LinkHandler.php b/src/Backend/Core/Field/LinkHandler.php index a9a84ca2f..e0d541d64 100644 --- a/src/Backend/Core/Field/LinkHandler.php +++ b/src/Backend/Core/Field/LinkHandler.php @@ -92,7 +92,7 @@ protected function doExpand(array $records): array { 'title' => $record['title'] ?? NULL, 'uri' => $record['uri'] ?? NULL, 'options' => [], - ], fn ($v): bool => $v !== NULL); + ], fn($v): bool => $v !== NULL); // UnroutedUrlAssembler::assemble() rejects a string 'options' value, so // query-string shorthand is parsed into an array. diff --git a/src/Backend/Core/Field/NameHandler.php b/src/Backend/Core/Field/NameHandler.php index f5704c536..ceda954f9 100644 --- a/src/Backend/Core/Field/NameHandler.php +++ b/src/Backend/Core/Field/NameHandler.php @@ -55,7 +55,7 @@ protected function normalize(mixed $values): array { } if (!is_array($values)) { - throw new \RuntimeException(sprintf('Name field value must be a string or an array, got %s.', get_debug_type($values))); + throw new \RuntimeException(sprintf('Name field value must be a string or an array. Got %s.', get_debug_type($values))); } if (!array_is_list($values)) { @@ -71,7 +71,7 @@ protected function normalize(mixed $values): array { } if (!is_array($value)) { - throw new \RuntimeException(sprintf('Name field delta %d must be a string or an array, got %s.', $delta, get_debug_type($value))); + throw new \RuntimeException(sprintf('Name field delta %d must be a string or an array. Got %s.', $delta, get_debug_type($value))); } $names[] = $this->normalizeArray($value, $enabled); @@ -156,7 +156,7 @@ protected function normalizeString(string $value, array $enabled): array { */ protected function normalizeArray(array $value, array $enabled): array { if ($value !== [] && !array_is_list($value) && $this->hasNumericKey($value)) { - throw new \RuntimeException('Cannot mix numeric and named keys in the same name value; use one shape consistently.'); + throw new \RuntimeException('Cannot mix positional and named keys in the same name value; use one shape consistently.'); } $name = []; diff --git a/src/Backend/Core/Field/Parser/EntityFieldParser.php b/src/Backend/Core/Field/Parser/EntityFieldParser.php index d2792f7af..37a5b837a 100644 --- a/src/Backend/Core/Field/Parser/EntityFieldParser.php +++ b/src/Backend/Core/Field/Parser/EntityFieldParser.php @@ -12,7 +12,7 @@ * Entity-field parser. * * Implements a syntax with a single uniform escape mechanism (double - * quotes) for compound values. Cells fall into two modes detected by the + * quotes) for compound values. Cells fall into 2 modes detected by the * value form, not by the spacing of separators: * * Scalar mode (no top-level 'key:"...' or 'key:[...]' pattern): @@ -22,7 +22,7 @@ * at the start of an item, where it begins a quoted string. * * Compound mode (top-level 'key:"...' or 'key:[...]' pattern present): - * - One or more 'key: value' columns separated by ','. + * - 1 or more 'key: value' columns separated by ','. * - Multi-value compound: records separated by ';'. * - Each column value MUST be a quoted string ('"..."') or token * ('[name:value]'). Bare values are a parse error. @@ -46,7 +46,7 @@ class EntityFieldParser implements EntityFieldParserInterface { protected array $ignoredProperties = []; /** - * Constructs the parser for one entity-type / bundle / classifier pairing. + * Constructs the parser for an entity-type / bundle / classifier pairing. * * @param string $entityType * The entity type ID. @@ -55,8 +55,7 @@ class EntityFieldParser implements EntityFieldParserInterface { * @param string|null $bundle * The bundle for the stub being parsed, or NULL for entity types * without bundles. When provided, bundle-scoped fields (F6-F9) are - * accepted as known fields, and their values are passed to the entity - * unchanged for the bundle's field item-list class to handle at save. + * accepted as known fields and their values are passed through unchanged. */ public function __construct( protected readonly string $entityType, @@ -94,8 +93,8 @@ public function parse(array $values): array { elseif (str_contains(substr($field, 1), ':')) { [$multicolumn_field, $multicolumn_column] = explode(':', $field); } - elseif (empty($multicolumn_field)) { - throw new \RuntimeException('Field name missing for ' . $field); + elseif ($multicolumn_field === '') { + throw new \RuntimeException(sprintf('Field name missing for "%s".', $field)); } else { $multicolumn_column = substr($field, 1); @@ -126,12 +125,11 @@ public function parse(array $values): array { } } else { - // The classifier splits base fields across F1-F4 (standard, computed - // read-only, computed writable, custom storage), so all four - // predicates are checked and a computed or custom-storage base field - // like 'moderation_state' is not flagged unknown. When the bundle is - // known, F6-F9 (bundle-scoped fields) count as known too, so fields - // contributed via 'hook_entity_bundle_field_info()' are recognised. + // The classifier splits base fields across F1-F4, so all 4 predicates + // are checked and a computed or custom-storage base field like + // 'moderation_state' is known. With a bundle, F6-F9 (bundle-scoped + // fields) are known too, so a field contributed via + // 'hook_entity_bundle_field_info()' is recognised. $is_known = $this->fieldClassifier->fieldIsBaseStandard($this->entityType, $field_name) || $this->fieldClassifier->fieldIsBaseComputedReadOnly($this->entityType, $field_name) || $this->fieldClassifier->fieldIsBaseComputedWritable($this->entityType, $field_name) @@ -147,7 +145,7 @@ public function parse(array $values): array { ); if (!$is_known && !in_array($field_name, $this->ignoredProperties, TRUE)) { - throw new \RuntimeException(sprintf('Field "%s" does not exist on entity type "%s".', $field_name, $this->entityType)); + throw new \RuntimeException(sprintf('The field "%s" does not exist on entity type "%s".', $field_name, $this->entityType)); } $parsed[$field] = $field_value; @@ -197,9 +195,9 @@ protected function parseCell(string $cell, bool $is_multicolumn): array { * * Compound mode is detected by the presence of a top-level * 'key:"...' or 'key:[...]' pattern - i.e. an identifier, optional - * whitespace, ':', optional whitespace, then '"' or '['. The scan - * respects quoted strings and bracketed tokens, so an embedded pattern - * inside a quoted scalar does not trigger compound mode. + * whitespace, ':', optional whitespace, then '"' or '['. The scan skips + * quoted strings and bracketed tokens, so an embedded pattern inside a + * quoted scalar does not trigger compound mode. */ protected function detectCompoundMode(string $cell): bool { $length = strlen($cell); @@ -218,8 +216,8 @@ protected function detectCompoundMode(string $cell): bool { continue; } - if (preg_match('/[a-z_][a-z0-9_]*/A', $cell, $match, 0, $i) === 1) { - $j = $i + strlen($match[0]); + if (preg_match('/[a-z_][a-z0-9_]*/A', $cell, $matches, 0, $i) === 1) { + $j = $i + strlen($matches[0]); while ($j < $length && ($cell[$j] === ' ' || $cell[$j] === "\t")) { $j++; @@ -237,7 +235,7 @@ protected function detectCompoundMode(string $cell): bool { } } - $i += strlen($match[0]); + $i += strlen($matches[0]); continue; } @@ -296,10 +294,9 @@ protected function parseScalarList(string $cell): array { else { $start = $i; - // '"' is only structural at the start of an item (handled in the - // branch above). Inside an unquoted item it can be any literal - // character (e.g. an HTML attribute value), so the stop set is the - // list separator ',' and the compound-record separator ';' only. + // '"' is only structural at the start of an item. Inside an unquoted + // item it is a literal character (e.g. in an HTML attribute value), + // so the stop set is ',' and ';' only. while ($i < $length && $cell[$i] !== ',' && $cell[$i] !== ';') { $i++; } @@ -328,7 +325,6 @@ protected function parseScalarList(string $cell): array { break; } - // $cell[$i] is now ',' $i++; if ($i >= $length) { @@ -381,7 +377,7 @@ protected function parseCompound(string $cell): array { } /** - * Parses one compound record (','-separated columns) into a key/value map. + * Parses 1 compound record (','-separated columns) into a key/value map. * * @return array * The parsed columns keyed by column name. @@ -419,13 +415,13 @@ protected function parseRecord(string $record, string $cell, int $base_offset): } /** - * Parses one 'key: value' column. + * Parses 1 'key: value' column. * * @return array{0: string, 1: string} * [$key, $value] */ protected function parseColumn(string $column, string $cell, int $base_offset): array { - if (preg_match('/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/s', $column, $match) !== 1) { + if (preg_match('/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/s', $column, $matches) !== 1) { throw new ParseException( 'invalid_column', $base_offset, @@ -435,8 +431,8 @@ protected function parseColumn(string $column, string $cell, int $base_offset): ); } - $key = $match[1]; - $value_raw_with_ws = $match[2]; + $key = $matches[1]; + $value_raw_with_ws = $matches[2]; $value_raw = trim($value_raw_with_ws); // The value position inside $cell: column start + length consumed up to // the trimmed value's first character. @@ -528,8 +524,8 @@ protected function splitTopLevel(string $cell, string $separator): array { /** * Advances past a '"..."' quoted string, returning the index after the close. * - * Matches the same escape grammar as 'readQuotedString()'. If the string - * is unterminated returns the cell length. + * Matches the same escape grammar as 'readQuotedString()'. For an + * unterminated string the return value is the cell length. */ protected function skipQuotedString(string $cell, int $i): int { $length = strlen($cell); @@ -554,7 +550,7 @@ protected function skipQuotedString(string $cell, int $i): int { /** * Advances past a '[...]' token, returning the index after the close. * - * If the token is unterminated returns the cell length. + * For an unterminated token the return value is the cell length. */ protected function skipToken(string $cell, int $i): int { $length = strlen($cell); @@ -571,9 +567,11 @@ protected function skipToken(string $cell, int $i): int { * Reads a '"..."' quoted string starting at the current offset. * * Advances $offset past the closing quote. Decodes the escapes \\, \", - * \n, \t, \r. Any other backslash sequence is a parse error. When called - * for cell-level diagnostics, $error_cell and $error_base_offset are used - * to report errors against the original cell rather than the fragment. + * \n, \t, \r; any other backslash sequence is a parse error. + * + * When called for cell-level diagnostics, $error_cell and + * $error_base_offset are used to report errors against the original cell + * rather than the fragment. */ protected function readQuotedString(string $fragment, int &$offset, ?string $error_cell = NULL, int $error_base_offset = 0): string { $error_cell ??= $fragment; @@ -639,9 +637,9 @@ protected function readQuotedString(string $fragment, int &$offset, ?string $err * Reads a '[name:value]' token starting at the current offset. * * Advances $offset past the closing bracket and returns the verbatim - * '[...]' substring (downstream field handlers expand the token). When - * called for cell-level diagnostics, $error_cell and $error_base_offset - * are used to report errors against the original cell. + * '[...]' substring, which becomes the column value unchanged. When called + * for cell-level diagnostics, $error_cell and $error_base_offset are used + * to report errors against the original cell. */ protected function readToken(string $fragment, int &$offset, ?string $error_cell = NULL, int $error_base_offset = 0): string { $error_cell ??= $fragment; diff --git a/src/Backend/Core/Field/Parser/EntityFieldParserInterface.php b/src/Backend/Core/Field/Parser/EntityFieldParserInterface.php index 429b16f00..1a18aac45 100644 --- a/src/Backend/Core/Field/Parser/EntityFieldParserInterface.php +++ b/src/Backend/Core/Field/Parser/EntityFieldParserInterface.php @@ -8,17 +8,17 @@ * Contract for entity-field value parsers. * * Implementations transform a raw map of field-name to cell-text pairs (as - * returned by 'EntityStubInterface::getValues()') into a final map suitable - * for handing back to 'EntityStubInterface::setValues()'. Each implementation - * owns all syntactic concerns (CSV multi-value splitting, compound column - * splitting, inline named-column interpretation, 'field:column' / ':column' - * multicolumn-header merging) and all field-type semantics (configurable vs - * base vs ignored vs unknown). + * returned by 'EntityStubInterface::getValues()') into a final map for + * 'EntityStubInterface::setValues()'. Each implementation owns all syntactic + * concerns (CSV multi-value splitting, compound column splitting, inline + * named-column interpretation, 'field:column' / ':column' multicolumn-header + * merging) and all field-type semantics (configurable vs base vs ignored vs + * unknown). * - * Heavier dependencies needed for those decisions (entity type, classifier) - * are constructor-injected. Per-call configuration that may vary between - * stubs (e.g. the list of ignored property names) is set via fluent setters - * before 'parse()' is called. + * Dependencies those decisions require (entity type, classifier) are + * constructor-injected. Per-call configuration that may vary between stubs + * (e.g. the list of ignored property names) is set via fluent setters before + * 'parse()' is called. */ interface EntityFieldParserInterface { @@ -40,8 +40,7 @@ public function parse(array $values): array; * Sets property names accepted without field-type validation. * * Backend-level creation hints on the stub (e.g. 'author', 'role', - * 'vocabulary_machine_name') are not real Drupal fields; the backend's - * create methods consume them. + * 'vocabulary_machine_name') are not Drupal fields. * * @param string[] $properties * Property names to accept without validation. diff --git a/src/Backend/Core/Field/Parser/Exception/MultipleParseException.php b/src/Backend/Core/Field/Parser/Exception/MultipleParseException.php index 2d69e1640..0cf7fca6d 100644 --- a/src/Backend/Core/Field/Parser/Exception/MultipleParseException.php +++ b/src/Backend/Core/Field/Parser/Exception/MultipleParseException.php @@ -7,7 +7,7 @@ /** * Container for multiple parse errors detected in a single cell. * - * Parsers collect all errors detected in one cell before throwing, so the + * Parsers collect all errors detected in 1 cell before throwing, so the * test author sees every problem at once. */ class MultipleParseException extends ParseException { @@ -16,7 +16,7 @@ class MultipleParseException extends ParseException { * Wraps multiple parse errors detected in a single cell. * * @param ParseException[] $errors - * The individual parse errors. Must contain at least one entry. + * The individual parse errors. Must contain at least 1 entry. * @param string $cell * The cell value being parsed when the errors were collected. * @param \Throwable|null $previous @@ -27,8 +27,8 @@ public function __construct(public readonly array $errors, string $cell, ?\Throw throw new \RuntimeException('MultipleParseException requires at least one error.'); } - // 'reset()' reads the first error by iteration order, because a caller - // that filtered its errors passes a list with gaps in its keys. + // 'reset()' reads the first error by iteration order, because '$errors' + // may have gaps in its keys. $first = reset($errors); parent::__construct($first->errorCode, $first->offset, $cell, $this->buildDescription($errors), NULL, $previous); diff --git a/src/Backend/Core/Field/ReferenceTarget.php b/src/Backend/Core/Field/ReferenceTarget.php index 473ccbc04..c66aab9b2 100644 --- a/src/Backend/Core/Field/ReferenceTarget.php +++ b/src/Backend/Core/Field/ReferenceTarget.php @@ -6,9 +6,6 @@ /** * Immutable entity-type facts a reference field resolves its lookups against. - * - * Read once per expansion so a field holding several deltas derives the entity - * type definition a single time. */ final readonly class ReferenceTarget { diff --git a/src/Backend/Core/Field/SmartdateHandler.php b/src/Backend/Core/Field/SmartdateHandler.php index 8cca4416f..cb5248d58 100644 --- a/src/Backend/Core/Field/SmartdateHandler.php +++ b/src/Backend/Core/Field/SmartdateHandler.php @@ -17,8 +17,8 @@ protected function normalize(mixed $values): array { return []; } - // A bare scalar is the start of a single delta; wrapping it here lets the - // positional branch below read it as '[start]'. + // A bare scalar is the start of a single delta, so it becomes the + // positional record '[start]'. if (!is_array($values)) { $values = [$values]; } diff --git a/src/Backend/DrupalBackend.php b/src/Backend/DrupalBackend.php index e29cb8221..6c3ecd26e 100644 --- a/src/Backend/DrupalBackend.php +++ b/src/Backend/DrupalBackend.php @@ -53,7 +53,7 @@ public function __construct(string $drupal_root, protected readonly string $uri) $resolved = realpath($drupal_root); if ($resolved === FALSE) { - throw new BootstrapException(sprintf('No Drupal installation found at %s', $drupal_root)); + throw new BootstrapException(sprintf('No Drupal installation found at %s.', $drupal_root)); } $this->drupalRoot = $resolved; @@ -99,12 +99,13 @@ public function getDrupalVersion(): int { /** * Sets the core from the current version. * - * Walks from the detected Drupal version down to the default Core class, - * using the first class that exists in the lookup chain: - * DrevOps\BehatSteps\Backend\Core{N}\Core → ... → DrevOps\BehatSteps\Backend\Core\Core. + * Uses the first class that exists in the lookup chain: + * 'DrevOps\BehatSteps\Backend\Core{N}\Core' for each major version from + * the detected one downwards, then 'DrevOps\BehatSteps\Backend\Core\Core'. * * @throws \DrevOps\BehatSteps\Backend\Exception\BootstrapException - * Thrown when no Core implementation is found for the detected version. + * When a version-specific Core class exists but does not implement + * 'CoreInterface'. */ public function setCoreFromVersion(): void { $version = $this->getDrupalVersion(); @@ -122,7 +123,7 @@ public function setCoreFromVersion(): void { $core = new $class($this->drupalRoot, $this->uri); if (!$core instanceof CoreInterface) { - throw new BootstrapException(sprintf('%s must implement %s', $class, CoreInterface::class)); + throw new BootstrapException(sprintf('%s must implement %s.', $class, CoreInterface::class)); } $this->core = $core; diff --git a/src/Backend/Drush/DrushResult.php b/src/Backend/Drush/DrushResult.php index 755affbfa..f5c50f774 100644 --- a/src/Backend/Drush/DrushResult.php +++ b/src/Backend/Drush/DrushResult.php @@ -7,9 +7,9 @@ /** * Immutable result of a Drush command execution. * - * Pairs the process exit code with its captured standard output and standard - * error, mirroring the values a finished Symfony 'Process' exposes through - * 'getExitCode()', 'getOutput()', and 'getErrorOutput()'. + * Pairs the process exit code with its captured standard output and + * standard error. They match the values a finished Symfony 'Process' + * exposes through 'getExitCode()', 'getOutput()', and 'getErrorOutput()'. */ final readonly class DrushResult { @@ -17,7 +17,7 @@ * Constructs a DrushResult. * * @param int $exitCode - * The command exit code. Zero indicates success. + * The command exit code, 0 for success. * @param string $output * The command's captured standard output. * @param string $errorOutput diff --git a/src/Backend/DrushBackend.php b/src/Backend/DrushBackend.php index 65b5f7755..566ee39e9 100644 --- a/src/Backend/DrushBackend.php +++ b/src/Backend/DrushBackend.php @@ -69,18 +69,18 @@ class DrushBackend implements DrushBackendInterface, CreationAliasCapabilityInte * Thrown when a required parameter is missing. */ public function __construct(?string $alias = NULL, ?string $root_path = NULL, string $binary = 'drush', ?Random $random = NULL) { - if (empty($alias) && empty($root_path)) { + if (($alias === NULL || $alias === '') && ($root_path === NULL || $root_path === '')) { throw new BootstrapException('A drush alias or root path is required.'); } - if (!empty($alias)) { + if ($alias !== NULL && $alias !== '') { $this->alias = ltrim($alias, '@'); } else { $resolved = realpath($root_path); if ($resolved === FALSE) { - throw new BootstrapException(sprintf('No Drupal installation found at %s', $root_path)); + throw new BootstrapException(sprintf('No Drupal installation found at %s.', $root_path)); } $this->root = $resolved; @@ -97,7 +97,7 @@ public function __construct(?string $alias = NULL, ?string $root_path = NULL, st } /** - * Populates the creation-alias registry with aliases this backend ships. + * Populates the creation-alias registry with the backend's default aliases. * * A subclass that adds custom aliases should override this method and * call 'parent::registerDefaultCreationAliases()' first. @@ -138,7 +138,6 @@ public function processBatch(): void { * {@inheritdoc} */ public function cacheClear(?string $type = 'all'): void { - // Drush-only cache clear does not need a full rebuild. if ($type === 'drush') { $this->drush('cache-clear', ['drush'], []); return; @@ -187,8 +186,8 @@ public function configSet(string $name, string $key, mixed $value): void { * {@inheritdoc} */ public function configExists(string $name): bool { - // 'config:get' refuses an object that does not exist, so its exit code - // answers this without a second command. + // 'config:get' exits non-zero for an object that does not exist, so the + // exit code indicates existence without a second command. return $this->drushResult('config:get', [$name], ['format' => 'json'])->exitCode === 0; } @@ -205,16 +204,15 @@ public function configGetData(string $name): array { * {@inheritdoc} */ public function configSetData(string $name, array $data): void { - // 'config:set' assigns only the keys it is handed and Drush exposes no - // whole-object replace, so the object is deleted first to drop the keys - // the new data does not carry. The delete and the write are 2 commands, - // so the object is read first and put back when the write fails, rather - // than being left missing. + // Drush exposes no whole-object replace and 'config:set' assigns only the + // given keys, so the object is deleted first to drop its other keys. The + // delete and the write are 2 commands, so the previous data is read first + // and restored when the write fails. $previous = $this->configGetData($name); - // An object holding nothing has no keys to drop, and 'config:set' refuses - // to write an empty object back, so deleting one would leave nothing to - // restore from if the write then failed. + // An empty object has no keys to drop. 'config:set' rejects an empty + // object, so deleting one would leave nothing to restore when the write + // fails. if ($previous !== []) { $this->configDelete($name); } @@ -235,7 +233,7 @@ public function configSetData(string $name, array $data): void { // @codeCoverageIgnoreStart catch (\RuntimeException) { // Restoring failed as well. The write failure below is the error - // worth reporting, so this one is not allowed to replace it. + // reported, so the restore failure is discarded. } // @codeCoverageIgnoreEnd throw $e; @@ -264,7 +262,7 @@ protected function configWriteData(string $name, array $data): void { * {@inheritdoc} */ public function configDelete(string $name): void { - // 'config:delete' refuses an object that does not exist, while Drupal's + // 'config:delete' fails on an object that does not exist, while Drupal's // own config API treats deleting one as a no-op. if (!$this->configExists($name)) { return; @@ -297,8 +295,8 @@ protected function configRead(string $name, string $key, bool $with_overrides): $arguments = $key !== '' ? [$name, $key] : [$name]; $result = $this->drushResult('config:get', $arguments, $options); - // A missing object is an error to Drush and an absent value to Drupal's - // config API; the API's answer is the one the capability promises. + // A missing object is an error to Drush but an absent value to Drupal's + // config API, and the capability contract matches the API. if ($result->exitCode !== 0) { return NULL; } @@ -306,8 +304,8 @@ protected function configRead(string $name, string $key, bool $with_overrides): $decoded = json_decode(trim($result->output), TRUE); $envelope_key = $name . ':' . $key; - // Asked for a single key, 'config:get' answers with a 1-entry map keyed - // ':' rather than the bare value. + // For a single key, 'config:get' returns a 1-entry map keyed + // ':' instead of the bare value. if ($key !== '' && is_array($decoded) && array_key_exists($envelope_key, $decoded)) { return $decoded[$envelope_key]; } @@ -327,7 +325,7 @@ public function stateGet(string $name): mixed { $decoded = json_decode(trim($result->output), TRUE); - // 'state:get' answers with a 1-entry map keyed by the state key. + // 'state:get' returns a 1-entry map keyed by the state key. if (is_array($decoded) && array_key_exists($name, $decoded)) { return $decoded[$name]; } @@ -355,8 +353,8 @@ public function stateDelete(string $name): void { /** * {@inheritdoc} * - * Drush exposes no existence check over the state key-value store, so a key - * holding NULL reads the same as an absent one on this backend. + * Drush exposes no existence check over the state key-value store. A key + * holding NULL therefore reads the same as an absent one on this backend. */ public function stateExists(string $name): bool { return $this->stateGet($name) !== NULL; @@ -463,9 +461,7 @@ public function userCreate(EntityStubInterface $stub): void { $uid = $this->parseUserId($result); if (!$uid) { - // Without an id the account cannot be referenced again, so post-create - // aliases such as roles would silently never be applied. - throw new \RuntimeException(sprintf("Drush did not report a user id after creating '%s'. Output: %s", $stub->getValue('name'), $result)); + throw new \RuntimeException(sprintf('Drush did not report a user id after creating "%s". Output: %s', $stub->getValue('name'), $result)); } $stub->setValue('uid', $uid); @@ -578,7 +574,7 @@ public function drushResult(string $command, array $arguments = [], array $optio * Options to pass to Drush. * * @return string - * The command's stdout, or its stderr when stdout is empty. + * The command's stdout, or its stderr when stdout is empty or '0'. * * @throws \RuntimeException * When the command exits with a non-zero status. @@ -587,7 +583,7 @@ public function drush(string $command, array $arguments = [], array $options = [ $result = $this->drushResult($command, $arguments, $options); if ($result->exitCode !== 0) { - throw new \RuntimeException(sprintf("Drush command '%s' exited with code %d. %s", $command, $result->exitCode, $result->errorOutput)); + throw new \RuntimeException(sprintf('Drush command "%s" exited with code %d. %s', $command, $result->exitCode, $result->errorOutput)); } // Some Drush commands write to stderr instead of stdout. @@ -661,11 +657,11 @@ protected function resolveProjectDrush(string $fallback): string { * data row. */ protected function parseUserId(string $info): ?int { - if (preg_match('/User ID\s+:\s+(\d+)/', $info, $matches)) { + if (preg_match('/User ID\s+:\s+(\d+)/', $info, $matches) === 1) { return (int) $matches[1]; } - if (preg_match('/User ID/', $info)) { + if (preg_match('/User ID/', $info) === 1) { $lines = explode("\n", trim($info)); foreach ($lines as $line) { @@ -679,7 +675,7 @@ protected function parseUserId(string $info): ?int { continue; } - if (preg_match('/^\s*(\d+)\s/', $line, $matches)) { + if (preg_match('/^\s*(\d+)\s/', $line, $matches) === 1) { return (int) $matches[1]; } } diff --git a/src/Backend/Entity/EntityStub.php b/src/Backend/Entity/EntityStub.php index f6a8f9b46..5a70eb8a4 100644 --- a/src/Backend/Entity/EntityStub.php +++ b/src/Backend/Entity/EntityStub.php @@ -7,9 +7,10 @@ /** * Typed envelope for creating, tracking, and cleaning up a Drupal entity. * - * Mirrors Drupal Core's own 'Entity::create($type, $values)' shape - one - * final class, no subclasses, with the entity type and bundle pinned at - * construction time and a mutable values bag plus a saved-entity slot. + * Like Drupal Core's 'getStorage($type)->create($values)', which creates an + * entity of any type from 1 call, the stub is 1 final class with no + * subclasses. The entity type and bundle are pinned at construction time; + * the values bag and the saved-entity slot are mutable. */ final class EntityStub implements EntityStubInterface { diff --git a/src/Backend/Entity/EntityStubInterface.php b/src/Backend/Entity/EntityStubInterface.php index 7663a5024..6c84248b5 100644 --- a/src/Backend/Entity/EntityStubInterface.php +++ b/src/Backend/Entity/EntityStubInterface.php @@ -14,11 +14,10 @@ interface EntityStubInterface { /** - * Default bundle key for entity types that do not declare one. + * Bundle key a stub starts with, until 'setBundleKey()' replaces it. * - * Drupal Core's most common bundle key is 'type' (used by 'node', - * 'block_content', 'entity_test', and others), so it is the fallback when - * the caller has not specified a bundle key. + * Drupal Core's most common bundle key is 'type', used by 'node', + * 'block_content', 'entity_test' and others. */ public const DEFAULT_BUNDLE_KEY = 'type'; diff --git a/src/Backend/Exception/CreationAliasResolutionException.php b/src/Backend/Exception/CreationAliasResolutionException.php index 1c33362fe..307670b40 100644 --- a/src/Backend/Exception/CreationAliasResolutionException.php +++ b/src/Backend/Exception/CreationAliasResolutionException.php @@ -8,9 +8,9 @@ * Thrown when a creation alias cannot resolve its value. * * Raised by alias implementations when a stub property cannot be - * translated into a Drupal storage operation - for example, an 'author' - * alias referencing a username with no matching user account, or a - * 'parent' term name that does not exist in the target vocabulary. + * translated into a Drupal storage operation. Examples are an 'author' + * alias with a username that matches no account, and a 'parent' term name + * absent from the target vocabulary. */ class CreationAliasResolutionException extends Exception { } diff --git a/src/Behat/Config/ConfigSchemaReader.php b/src/Behat/Config/ConfigSchemaReader.php index cd98ad639..38ebe50a3 100644 --- a/src/Behat/Config/ConfigSchemaReader.php +++ b/src/Behat/Config/ConfigSchemaReader.php @@ -19,8 +19,8 @@ class ConfigSchemaReader { /** * Declared options, keyed by context class name. * - * Reflection over every method of a context composing forty traits is too - * expensive to repeat per option read, and a class's declarations cannot + * Reflection over every method of a context composing 40 traits is too + * expensive to repeat per option read. A class's declarations cannot * change within a run. * * @var array>> diff --git a/src/Behat/Config/GroupName.php b/src/Behat/Config/GroupName.php index 9a79b521c..852712542 100644 --- a/src/Behat/Config/GroupName.php +++ b/src/Behat/Config/GroupName.php @@ -7,14 +7,16 @@ /** * Converts between an option group name and the names it derives from. * - * A group is named after the trait that declares it, in snake case, and that - * trait declares its options in a method carrying the same name in camel case. - * The runtime and the documentation generator both walk that mapping, so both - * go through here and cannot disagree on it. + * A group is named after the trait that declares it, in snake case. That + * trait declares its options in a method carrying the same name in camel + * case. * - * Every conversion runs towards the group name, never back to the trait name: - * a run of capitals reads as one word, so 'APIClientTrait' and 'ApiClientTrait' - * both give 'api_client' and the group alone cannot say which was written. + * The runtime and the documentation generator both walk that mapping, so + * both go through here and cannot disagree on it. + * + * Every conversion runs towards the group name, never back to the trait name. + * A run of capitals reads as 1 word, so 'APIClientTrait' and 'ApiClientTrait' + * both give 'api_client', and the group alone does not identify the spelling. */ final class GroupName { @@ -31,8 +33,8 @@ final class GroupName { /** * Converts a camel case method prefix to its group name. * - * A run of capitals is one word, so the group a trait name derives to is the - * group its method prefix derives to: 'APIClient' and 'apiClient' both give + * A run of capitals is 1 word, so the group a trait name derives to is the + * group its method prefix derives to. 'APIClient' and 'apiClient' both give * 'api_client'. * * @param string $prefix diff --git a/src/Behat/Config/Option.php b/src/Behat/Config/Option.php index ef593d4e5..7a2d5ca6e 100644 --- a/src/Behat/Config/Option.php +++ b/src/Behat/Config/Option.php @@ -9,9 +9,11 @@ /** * One option a step trait declares. * - * The declared default carries the option's type: a configured value is read - * as that type, and a value that cannot be is an error naming both. A default - * of NULL names no type, so anything configured against it passes through. + * The declared default carries the option's type. A configured value is read + * as that type, and a value that cannot be is an error naming both. + * + * A default of NULL names no type, so anything configured against it passes + * through. * * @see \DrevOps\BehatSteps\Behat\Config\ConfigSchemaReader */ @@ -72,8 +74,8 @@ public function __construct( } // Every hook of the trait is switched on this one option, and the skip tag - // binds it to FALSE, so a declaration naming another type would fail at the - // hook that read it rather than here. + // binds it to FALSE. A declaration naming another type would fail at the + // hook that reads it rather than here. if ($name === self::ENABLED && !is_bool($default)) { throw new \RuntimeException(sprintf('The "%s" option switches its trait on and off, so it defaults to a boolean.', $name)); } diff --git a/src/Behat/Config/TagOverrides.php b/src/Behat/Config/TagOverrides.php index 91efae172..f0ab7fa70 100644 --- a/src/Behat/Config/TagOverrides.php +++ b/src/Behat/Config/TagOverrides.php @@ -39,8 +39,6 @@ class TagOverrides { * The value, replaced by whatever the last matching tag sets. */ public function apply(string $group, Option $option, mixed $value, array $tags): mixed { - // An 'enabled' option is also switched off by the library's one skip tag, - // named after the trait the group belongs to. $switchable = $option->name === Option::ENABLED; if ($tags === [] || ($option->tags === [] && !$switchable)) { @@ -66,9 +64,11 @@ public function apply(string $group, Option $option, mixed $value, array $tags): * Whether a skip tag names the trait that owns a group. * * The tag carries the trait's own name, which a group name cannot be - * converted back into: a run of capitals reads as one word, so 'APIClient' - * and 'ApiClient' both give 'api_client'. The tag is read forwards instead, - * and any spelling of the trait that derives the group matches it. + * converted back into. A run of capitals reads as 1 word, so 'APIClient' + * and 'ApiClient' both give 'api_client'. + * + * The tag is read forwards instead, and any spelling of the trait that + * derives the group matches it. * * @param string $tag * A tag the scenario or its feature carries, without a leading '@'. diff --git a/src/Behat/Config/TraitOptionResolver.php b/src/Behat/Config/TraitOptionResolver.php index fdb969b61..2179c4119 100644 --- a/src/Behat/Config/TraitOptionResolver.php +++ b/src/Behat/Config/TraitOptionResolver.php @@ -12,12 +12,14 @@ * * A value is taken from the first of these that sets it: the scenario's tags, * the feature's tags, the context's 'config' argument, the extension's 'steps' - * section, the declaration's own default. The configuration layers settle when - * this object is built; the tag layers are read per option, because the tags - * belong to whichever scenario is running. + * section, the declaration's own default. * - * It holds no Behat class, reflects over nothing and knows no context beyond - * the class name it names in a failure message. + * The configuration layers settle when this object is built. The tag layers + * are read per option, because the tags belong to whichever scenario is + * running. + * + * It holds no Behat class, reflects over nothing and references no context + * beyond the class name it names in a failure message. */ class TraitOptionResolver implements TraitOptionResolverInterface { @@ -35,11 +37,11 @@ class TraitOptionResolver implements TraitOptionResolverInterface { * The context whose traits declared the options, for failure messages. * @param array> $declarations * Declared options, keyed by group name and then by option name. - * @param array $steps - * The extension's 'steps' section, read permissively. * @param array $config * The context's 'config' argument, read strictly. - * @param \DrevOps\BehatSteps\Behat\Manager\ScenarioTagRegistryInterface $scenarioTags + * @param array $steps + * The extension's 'steps' section, read permissively. + * @param \DrevOps\BehatSteps\Behat\Manager\ScenarioTagRegistryInterface $scenarioTagRegistry * The tags the running scenario carries. * @param \DrevOps\BehatSteps\Behat\Config\TagOverrides $tagOverrides * Applies the tag layers of one option. @@ -52,9 +54,9 @@ class TraitOptionResolver implements TraitOptionResolverInterface { public function __construct( protected readonly string $contextClass, protected readonly array $declarations, - array $steps, array $config, - protected readonly ScenarioTagRegistryInterface $scenarioTags, + array $steps, + protected readonly ScenarioTagRegistryInterface $scenarioTagRegistry, protected readonly TagOverrides $tagOverrides, ) { $resolved = $this->defaults(); @@ -78,7 +80,7 @@ public function raw(string $group, string $key): mixed { throw new \RuntimeException(sprintf('No trait in %s declares the option "%s.%s". Declared options: %s.', $this->contextClass, $group, $key, $this->optionList())); } - return $this->tagOverrides->apply($group, $this->declarations[$group][$key], $this->resolved[$group][$key], $this->scenarioTags->getTags()); + return $this->tagOverrides->apply($group, $this->declarations[$group][$key], $this->resolved[$group][$key], $this->scenarioTagRegistry->getTags()); } /** diff --git a/src/Behat/Config/TraitOptionResolverFactory.php b/src/Behat/Config/TraitOptionResolverFactory.php index 6fd0fc8b9..e130b5721 100644 --- a/src/Behat/Config/TraitOptionResolverFactory.php +++ b/src/Behat/Config/TraitOptionResolverFactory.php @@ -18,12 +18,12 @@ class TraitOptionResolverFactory implements TraitOptionResolverFactoryInterface /** * Reads the option declarations of a context class. */ - protected ConfigSchemaReader $reader; + protected ConfigSchemaReader $configSchemaReader; /** * Holds the tags the running scenario carries. */ - protected ScenarioTagRegistryInterface $scenarioTags; + protected ScenarioTagRegistryInterface $scenarioTagRegistry; /** * Applies the tag layers of one option. @@ -33,16 +33,16 @@ class TraitOptionResolverFactory implements TraitOptionResolverFactoryInterface /** * Constructs a TraitOptionResolverFactory. * - * @param \DrevOps\BehatSteps\Behat\Config\ConfigSchemaReader|null $reader + * @param \DrevOps\BehatSteps\Behat\Config\ConfigSchemaReader|null $config_schema_reader * Reads the option declarations of a context class. - * @param \DrevOps\BehatSteps\Behat\Manager\ScenarioTagRegistryInterface|null $scenario_tags + * @param \DrevOps\BehatSteps\Behat\Manager\ScenarioTagRegistryInterface|null $scenario_tag_registry * Holds the tags the running scenario carries. * @param \DrevOps\BehatSteps\Behat\Config\TagOverrides|null $tag_overrides * Applies the tag layers of one option. */ - public function __construct(?ConfigSchemaReader $reader = NULL, ?ScenarioTagRegistryInterface $scenario_tags = NULL, ?TagOverrides $tag_overrides = NULL) { - $this->reader = $reader ?? new ConfigSchemaReader(); - $this->scenarioTags = $scenario_tags ?? new ScenarioTagRegistry(); + public function __construct(?ConfigSchemaReader $config_schema_reader = NULL, ?ScenarioTagRegistryInterface $scenario_tag_registry = NULL, ?TagOverrides $tag_overrides = NULL) { + $this->configSchemaReader = $config_schema_reader ?? new ConfigSchemaReader(); + $this->scenarioTagRegistry = $scenario_tag_registry ?? new ScenarioTagRegistry(); $this->tagOverrides = $tag_overrides ?? new TagOverrides(); } @@ -50,7 +50,7 @@ public function __construct(?ConfigSchemaReader $reader = NULL, ?ScenarioTagRegi * {@inheritdoc} */ public function create(string $context_class, array $config, array $steps): TraitOptionResolverInterface { - return new TraitOptionResolver($context_class, $this->reader->read($context_class), $steps, $config, $this->scenarioTags, $this->tagOverrides); + return new TraitOptionResolver($context_class, $this->configSchemaReader->read($context_class), $config, $steps, $this->scenarioTagRegistry, $this->tagOverrides); } } diff --git a/src/Behat/Config/TraitOptionResolverFactoryInterface.php b/src/Behat/Config/TraitOptionResolverFactoryInterface.php index fe49e52c8..0f6fa9d95 100644 --- a/src/Behat/Config/TraitOptionResolverFactoryInterface.php +++ b/src/Behat/Config/TraitOptionResolverFactoryInterface.php @@ -7,9 +7,10 @@ /** * Interface for classes that build a context's option resolver. * - * A resolver depends on the context class that declared the options and on the - * 'config' argument that context was given, so it cannot be a container - * service. Registering another implementation of this interface under + * A resolver depends on the context class that declared the options and on + * that context's 'config' argument, so it cannot be a container service. + * + * Registering another implementation of this interface under * 'behat_steps.config.resolver_factory' replaces option resolution for every * context at once. */ diff --git a/src/Behat/Context/BackendAwareInterface.php b/src/Behat/Context/BackendAwareInterface.php index 2c52e1194..7572fd68d 100644 --- a/src/Behat/Context/BackendAwareInterface.php +++ b/src/Behat/Context/BackendAwareInterface.php @@ -42,7 +42,7 @@ public function getBackendRegistry(): BackendRegistryInterface; * @internal * Injection point called by the context initializer. */ - public function setDispatcher(HookDispatcher $dispatcher): void; + public function setHookDispatcher(HookDispatcher $hook_dispatcher): void; /** * Sets the basic authenticator. diff --git a/src/Behat/Context/DrupalContext.php b/src/Behat/Context/DrupalContext.php index d907e0b7f..2c6ca759a 100644 --- a/src/Behat/Context/DrupalContext.php +++ b/src/Behat/Context/DrupalContext.php @@ -38,14 +38,16 @@ * Zero-config context carrying the whole vocabulary a Drupal suite needs. * * Extending this context is enough to write features against a Drupal site - * without writing any PHP: it is 'WebContext' plus every trait under - * 'Steps\Drupal', so a Drupal project extends one class and gets all 57 step - * traits. Each of those traits composes the helpers it needs, so the traits - * that create entities also compose the entity teardown. + * without writing any PHP. It is 'WebContext' plus every trait under + * 'Steps\Drupal', so a Drupal project extends 1 class and gets all 57 step + * traits. + * + * Each of those traits composes the helpers it needs, so the traits that + * create entities also compose the entity teardown. * * A trait for a contrib module resolves nothing until one of its steps runs, - * and then fails with a message naming the module, so composing all of them - * costs a project nothing. + * and then fails with a message naming the module. Composing all of them + * therefore costs a project nothing. * * Registering this context beside 'WebContext' is fatal, because the 28 web * traits would register their steps twice. 'WebContext::assertOneContext()' diff --git a/src/Behat/Context/Initializer/BackendAwareInitializer.php b/src/Behat/Context/Initializer/BackendAwareInitializer.php index db6686d6e..30223f471 100644 --- a/src/Behat/Context/Initializer/BackendAwareInitializer.php +++ b/src/Behat/Context/Initializer/BackendAwareInitializer.php @@ -72,12 +72,12 @@ public function initializeContext(Context $context): void { } $context->setBackendRegistry($this->backendRegistry); - $context->setDispatcher($this->hookDispatcher); + $context->setHookDispatcher($this->hookDispatcher); $context->setBasicAuthenticator($this->basicAuthenticator); $context->setHttpClientFactory($this->httpClientFactory); - // Set last: it rebuilds the resolver, so the parameters set above are the - // ones the rebuild reads its 'steps' section from. + // Set last: a context that rebuilds its resolver in this call reads the + // 'steps' section from the parameters set above. $context->setOptionResolverFactory($this->optionResolverFactory); } diff --git a/src/Behat/Context/WebContext.php b/src/Behat/Context/WebContext.php index 9cc866f7d..87b449f1d 100644 --- a/src/Behat/Context/WebContext.php +++ b/src/Behat/Context/WebContext.php @@ -44,7 +44,7 @@ * 'WebRawContext' plus every trait under 'Steps\Web'. * * A trait that can fail a scenario on a check the scenario did not ask for - * carries an 'enabled' option, so a project switches it off through + * carries an 'enabled' option. A project switches it off through * configuration rather than by composing its own context. * * A Drupal suite extends 'DrupalContext', which extends this class, so it @@ -87,12 +87,12 @@ class WebContext extends WebRawContext { /** * Rejects a suite that registers this context and a subclass of it. * - * Both register the same 28 web traits, and Behat reports that as a + * Both register the same 28 web traits. Behat reports that as a * 'RedundantStepException' naming whichever step text it reached first, * which says nothing about the cause. * * @throws \RuntimeException - * When the suite registers two contexts that both carry this class. + * When the suite registers 2 contexts that both carry this class. */ #[BeforeSuite] public static function assertOneContext(BeforeSuiteScope $scope): void { diff --git a/src/Behat/Context/WebRawContext.php b/src/Behat/Context/WebRawContext.php index 327be36da..3013ac8ba 100644 --- a/src/Behat/Context/WebRawContext.php +++ b/src/Behat/Context/WebRawContext.php @@ -40,15 +40,15 @@ * Provides backend access, authentication delegation, option resolution, * prerequisite checks and the hook dispatcher, and composes 3 of the web * helper traits. It registers no step definitions and references no Drupal - * class beyond 'Random', which a layer lint holds. + * class beyond 'Random'. * - * Extend this to compose a context out of a chosen set of traits; extend - * 'WebContext' instead to get the whole web vocabulary, or 'DrupalContext' - * to get the Drupal vocabulary on top of it. + * A context composed out of a chosen set of traits extends this class. + * 'WebContext' adds the whole web vocabulary, and 'DrupalContext' adds the + * Drupal vocabulary on top of that. * * The helper traits it composes are on '$this' for a consuming project's own - * step definitions, and composing one again in a step trait shares the same - * state rather than duplicating it. + * step definitions. A step trait that composes one of them again shares the + * same state. * * @see \DrevOps\BehatSteps\Behat\Context\WebContext * @see \DrevOps\BehatSteps\Behat\Context\DrupalContext @@ -70,12 +70,12 @@ class WebRawContext extends RawMinkContext implements BackendAwareInterface { /** * Resolves what the session's browser driver can do. */ - protected ?BrowserCapabilityResolver $browserResolver = NULL; + protected ?BrowserCapabilityResolver $browserCapabilityResolver = NULL; /** * Hook dispatcher. */ - protected ?HookDispatcher $dispatcher = NULL; + protected ?HookDispatcher $hookDispatcher = NULL; /** * Applies webserver-level basic auth to the session. @@ -93,7 +93,11 @@ class WebRawContext extends RawMinkContext implements BackendAwareInterface { protected ?TraitOptionResolverFactoryInterface $optionResolverFactory = NULL; /** - * Resolves the options this context's traits declare, NULL until first read. + * Resolves the options this context's traits declare. + * + * Built by the constructor, then NULL between a 'setParameters()' or + * 'setOptionResolverFactory()' call and the next 'getOptionResolver()' + * call, which rebuilds it. */ protected ?TraitOptionResolverInterface $optionResolver = NULL; @@ -110,9 +114,9 @@ class WebRawContext extends RawMinkContext implements BackendAwareInterface { * does not match the type its declaration defaults to. */ public function __construct(protected array $config = []) { - // Resolved here rather than on first read, so a typo fails while Behat - // builds the context instead of at the step that would have read it. The - // extension's 'steps' section arrives later, through 'setParameters()'. + // Built here so a mistyped option fails while Behat builds the context, + // before any step reads it. The extension's 'steps' section arrives later, + // through 'setParameters()'. $this->optionResolver = $this->buildOptionResolver(); } @@ -146,8 +150,8 @@ public function getBackendRegistry(): BackendRegistryInterface { /** * {@inheritdoc} */ - public function setDispatcher(HookDispatcher $dispatcher): void { - $this->dispatcher = $dispatcher; + public function setHookDispatcher(HookDispatcher $hook_dispatcher): void { + $this->hookDispatcher = $hook_dispatcher; } /** @@ -227,8 +231,8 @@ public function httpDetachedClient(array $options = []): AbstractBrowser { * Returns a one-off browser that carries no scenario state. * * It applies the connection options the site's 'browserkit_http' session - * declares, and nothing from the scenario, so it suits requests that are - * not the visitor's own, such as fetching a script from a CDN. + * declares, and nothing from the scenario. It suits requests that are not + * the visitor's own, such as fetching a script from a CDN. * * @param array $options * Symfony HttpClient options for this browser, such as a 'timeout'. @@ -305,8 +309,8 @@ public function getBackend(string $name): BackendInterface { * Returns the highest-priority backend providing the given capability. * * A step names the capability it needs and never a backend, so the shipped - * vocabulary stays portable: a project that registers its own backend gets - * the step working as soon as that backend implements the interface. + * vocabulary stays portable. A backend a project registers serves the step + * as soon as it implements the interface. * * @param class-string $capability * The capability interface the caller needs. @@ -327,14 +331,14 @@ public function backendFor(string $capability): object { * Returns the adapter providing a browser capability for this session. * * The browser half of the vocabulary resolves its capabilities separately - * from the Drupal half: a Mink session runs exactly 1 browser driver, so + * from the Drupal half. A Mink session runs exactly 1 browser driver, so * there is no ordered list to walk and nothing to bootstrap. * * @param class-string $capability * The browser capability interface the caller needs. * * @return T - * The adapter speaking for the session's browser driver. + * The adapter for the session's browser driver. * * @throws \Behat\Mink\Exception\UnsupportedDriverActionException * When the session's browser driver does not provide the capability. @@ -342,29 +346,29 @@ public function backendFor(string $capability): object { * @template T of object */ public function browserDriverFor(string $capability): object { - return $this->getBrowserResolver()->resolve($this->getSession()->getDriver(), $capability); + return $this->getBrowserCapabilityResolver()->resolve($this->getSession()->getDriver(), $capability); } /** * Whether this session's browser driver provides a browser capability. * - * A step that degrades gracefully without the capability asks this; a step + * A step that degrades gracefully without the capability calls this; a step * that cannot proceed without it calls 'browserDriverFor()'. * * @param class-string $capability * The browser capability interface to look for. */ public function browserDriverHas(string $capability): bool { - return $this->getBrowserResolver()->has($this->getSession()->getDriver(), $capability); + return $this->getBrowserCapabilityResolver()->has($this->getSession()->getDriver(), $capability); } /** * Returns the browser capability resolver, creating it on first use. */ - public function getBrowserResolver(): BrowserCapabilityResolver { - $this->browserResolver ??= new BrowserCapabilityResolver(); + public function getBrowserCapabilityResolver(): BrowserCapabilityResolver { + $this->browserCapabilityResolver ??= new BrowserCapabilityResolver(); - return $this->browserResolver; + return $this->browserCapabilityResolver; } /** @@ -498,8 +502,10 @@ protected function buildOptionResolver(): TraitOptionResolverInterface { * * 'BEHAT_STEPS_DISABLE_CLEANUP' set to '1', 'true', 'yes' or 'on' * (case-insensitive) skips the AfterScenario teardown of entities, users - * and roles, so the state a failing scenario leaves behind can be - * inspected. The variable is not intended for CI runs. + * and roles. The state a failing scenario leaves behind can then be + * inspected. + * + * The variable is not intended for CI runs. */ protected function shouldCleanup(): bool { $env = getenv('BEHAT_STEPS_DISABLE_CLEANUP'); @@ -519,8 +525,8 @@ protected function shouldCleanup(): bool { * together, so the tag works on either line. * * A trait that declares an 'enabled' option is also switched off by that - * option, so a project turns the trait off for the whole profile or for one - * context instead of tagging every feature file. + * option. Through the option, a project turns the trait off for the whole + * profile or for 1 context instead of tagging every feature file. * * @param string $trait * The trait the hook belongs to, fully qualified or short. A hook passes @@ -548,8 +554,8 @@ protected function skipTag(string $trait, ScenarioScope $scope): bool { * Returns a backend providing a capability, reusing one already reached. * * A read-only query such as a module check returns the same result through - * any backend, so a backend the scenario already reached is returned before - * the first one in the list, and no second backend starts. + * any backend. A backend the scenario already reached is then returned + * before the first one in the list, so no second backend starts. * * @param class-string $capability * The capability interface the caller needs. @@ -571,10 +577,12 @@ protected function anyBackendFor(string $capability): object { /** * Asserts that the prerequisites a trait declares hold. * - * A trait declares them in a 'Prerequisites()' method, each naming a - * capability a backend in the scenario's list provides and, optionally, a - * check that backend passes. A backend the scenario already reached answers - * before the first one in the list, so checking never starts a second one. + * A trait declares them in a 'Prerequisites()' method. Each names + * a capability a backend in the scenario's list provides and, optionally, + * a check that backend passes. + * + * A backend the scenario already reached is used before the first one in + * the list, so checking never starts a second one. * * @param string $trait * The trait whose prerequisites to assert. A hook or a step passes @@ -596,8 +604,8 @@ protected function assertPrerequisites(string $trait): void { /** * Determines whether the prerequisites a trait declares hold. * - * A teardown asks this instead of asserting, so an unmet prerequisite never - * replaces a failure the scenario has already recorded. + * A teardown calls this instead of asserting, so an unmet prerequisite + * never replaces a failure the scenario has already recorded. * * @param string $trait * The trait whose prerequisites to check. A hook passes '__TRAIT__'. diff --git a/src/Behat/Hook/Attribute/DrupalHookInterface.php b/src/Behat/Hook/Attribute/DrupalHookInterface.php index 6711fe77e..ae0dc6d49 100644 --- a/src/Behat/Hook/Attribute/DrupalHookInterface.php +++ b/src/Behat/Hook/Attribute/DrupalHookInterface.php @@ -5,7 +5,9 @@ namespace DrevOps\BehatSteps\Behat\Hook\Attribute; /** - * Marker interface for the entity creation hook attributes. + * Contract for the entity creation hook attributes. + * + * Each attribute exposes the filter string it was declared with. */ interface DrupalHookInterface { diff --git a/src/Behat/Http/HttpClientFactoryInterface.php b/src/Behat/Http/HttpClientFactoryInterface.php index 196a27d87..145d66b2b 100644 --- a/src/Behat/Http/HttpClientFactoryInterface.php +++ b/src/Behat/Http/HttpClientFactoryInterface.php @@ -48,8 +48,8 @@ public function createDetached(HttpIdentity $identity, array $options = []): Abs * Returns a copy whose browsers send through a decorated transport. * * The decorator receives the shared transport, so the copy keeps the site's - * connection options. A context overriding its factory uses it to add - * retries, tracing or a mock to every detached and bare browser. + * connection options. Retries, tracing or a mock added this way apply to + * every detached and bare browser. * * @param callable(\Symfony\Contracts\HttpClient\HttpClientInterface): \Symfony\Contracts\HttpClient\HttpClientInterface $decorator * Receives the transport and returns the one to send through. diff --git a/src/Behat/Http/HttpIdentity.php b/src/Behat/Http/HttpIdentity.php index 7c92ba6d2..67b9d1a42 100644 --- a/src/Behat/Http/HttpIdentity.php +++ b/src/Behat/Http/HttpIdentity.php @@ -19,7 +19,7 @@ class HttpIdentity { * Constructs an HttpIdentity object. * * @param array $cookies - * Cookie values keyed by name, in the form they travel on the wire. + * Cookie values keyed by name, in wire form. * @param string $cookieUrl * The URL the cookies were read for. They are sent to its host and that * host's subdomains only. diff --git a/src/Behat/Listener/BackendListener.php b/src/Behat/Listener/BackendListener.php index a9c95fa6f..378d02b90 100644 --- a/src/Behat/Listener/BackendListener.php +++ b/src/Behat/Listener/BackendListener.php @@ -70,8 +70,9 @@ public static function getSubscribedEvents(): array { * tags name. Each group keeps the configured order, so the order the tags * are written in never changes the result. * - * The scenario's tags are published here rather than read from a hook scope, - * so a tag that sets a trait option applies to a step as well as to a hook. + * The scenario's tags are published here rather than read from a hook + * scope. A tag that sets a trait option thus applies to a step as well as + * to a hook. * * Both subscribed events carry a 'BeforeScenarioTested', an example's * scenario being the outline row itself. diff --git a/src/Behat/Manager/Authenticator.php b/src/Behat/Manager/Authenticator.php index d4ca914f0..39e466396 100644 --- a/src/Behat/Manager/Authenticator.php +++ b/src/Behat/Manager/Authenticator.php @@ -18,9 +18,9 @@ /** * Logs a user in and out of the site under test. * - * Takes a basic-auth applier rather than applying basic auth itself: a - * session reset drops request headers, so the credentials are reapplied - * afterwards, and that is the only overlap between the two concerns. + * Takes a basic-auth applier instead of applying basic auth itself. A session + * reset drops request headers, so the credentials are reapplied afterwards; + * that is the only overlap between the 2 concerns. */ class Authenticator implements AuthenticatorInterface, FastLogoutInterface { @@ -104,7 +104,7 @@ public function logIn(EntityStubInterface $user): void { 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); + $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()); } @@ -149,8 +149,6 @@ public function loggedIn(): bool { return FALSE; } - // 'getPage()' is declared non-nullable, but a stubbed session can return - // NULL, so this guard is not dead code. $page = $session->getPage(); if ($page === NULL) { return FALSE; @@ -177,9 +175,8 @@ public function loggedIn(): bool { } // As a last resort, a logout link means a user is logged in. A theme that - // defers header navigation (Critical CSS or a late JS render) may not - // have added the link yet. Poll for it within the 'login_wait' window - // that 'logIn()' also uses after submit. + // defers header navigation (Critical CSS or a late JS render) may add the + // link late, so the poll reuses the 'login_wait' window of 'logIn()'. $session->visit($this->locatePath('/')); $login_wait = (int) $this->getParameter('login_wait'); if ($login_wait > 0) { @@ -251,7 +248,7 @@ protected function backendLogin(EntityStubInterface $user): void { */ protected function backendLogout(): void { // Only a backend the scenario already reached can hold a backend session, - // and resolving one here would bootstrap it: teardown logs every scenario + // and resolving one here would bootstrap it. Teardown logs every scenario // out, so asking for the capability would boot Drupal for all of them. $this->backendRegistry->getResolvedBackendFor(AuthenticationCapabilityInterface::class)?->logout(); } diff --git a/src/Behat/Manager/BackendRegistry.php b/src/Behat/Manager/BackendRegistry.php index a80575e3b..c64fc4a70 100644 --- a/src/Behat/Manager/BackendRegistry.php +++ b/src/Behat/Manager/BackendRegistry.php @@ -28,7 +28,7 @@ class BackendRegistry implements BackendRegistryInterface { protected array $scenarioBackends = []; /** - * Backends handed out during the current scenario. + * Backends resolved during the current scenario. * * @var array */ diff --git a/src/Behat/Manager/BackendRegistryInterface.php b/src/Behat/Manager/BackendRegistryInterface.php index 5b4ebc15f..710e6fd22 100644 --- a/src/Behat/Manager/BackendRegistryInterface.php +++ b/src/Behat/Manager/BackendRegistryInterface.php @@ -10,11 +10,12 @@ /** * Holds the backends registered with a suite and resolves one by name. * - * Two name spaces meet here. A backend is registered under the name the + * 2 kinds of name apply. A backend is registered under the name the * extension builds it with ('drupal', 'drush', 'blackbox'), and a suite maps - * a Gherkin-facing tag name onto one of those. 'getBackend()' takes the tag - * name, because that is the name a test author writes; 'registerBackend()' - * takes the registered name. + * a Gherkin-facing tag name onto one of those. + * + * 'getBackend()' takes the tag name, because that is the name a test author + * writes; 'registerBackend()' takes the registered name. */ interface BackendRegistryInterface { @@ -39,9 +40,9 @@ public function getBackends(): array; /** * Sets the backend order the current scenario resolves against. * - * Resolution walks this order, so the first entry wins any capability it - * provides. Setting the order also clears the record of which backends the - * previous scenario resolved. + * Resolution walks this order, so the first entry providing a capability + * resolves it. Setting the order also clears the record of which backends + * the previous scenario resolved. * * @param array $backends * Ordered map of tag name to registered backend name. @@ -95,7 +96,7 @@ public function getBackendFor(string $capability): object; /** * Determines whether any backend in the scenario's order has a capability. * - * Bootstraps nothing, so a hook can call it before a scenario has used the + * Bootstraps nothing, so it is safe to call before a scenario has used the * site. * * @param class-string $capability @@ -106,9 +107,8 @@ public function hasCapability(string $capability): bool; /** * Returns a backend with the capability that this scenario already resolved. * - * Reports whether a step in this scenario resolved the capability, which is - * narrower than 'hasCapability()': a suite may list a cache-capable backend - * that no step in this scenario resolved. + * This is narrower than 'hasCapability()': a suite may list a cache-capable + * backend that no step in this scenario resolved. * * @param class-string $capability * The capability interface to look for. diff --git a/src/Behat/Manager/BasicAuthenticator.php b/src/Behat/Manager/BasicAuthenticator.php index c5ecef6a4..c0f2c78bf 100644 --- a/src/Behat/Manager/BasicAuthenticator.php +++ b/src/Behat/Manager/BasicAuthenticator.php @@ -11,8 +11,8 @@ /** * Applies webserver-level HTTP Basic authentication to the Mink session. * - * This is not user authentication. It carries no user, reads no site - * configuration and needs only the Mink session and the configured base URL, + * This is not user authentication: it carries no user and reads no site + * configuration. It needs only the Mink session and the configured base URL, * so a suite for a site behind basic auth uses it without any Drupal site. */ class BasicAuthenticator implements BasicAuthenticatorInterface { @@ -69,9 +69,9 @@ public function findCredentials(): ?array { $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). + // Userinfo is RFC 3986 encoded, where '+' is a literal plus and a space + // is '%20'. 'urldecode()' would turn a literal '+' into a space, so + // 'rawurldecode()' is used. 'username' => rawurldecode($name), 'password' => is_string($pass) ? rawurldecode($pass) : '', ]; diff --git a/src/Behat/Manager/BasicAuthenticatorInterface.php b/src/Behat/Manager/BasicAuthenticatorInterface.php index e72d7da62..04edcc636 100644 --- a/src/Behat/Manager/BasicAuthenticatorInterface.php +++ b/src/Behat/Manager/BasicAuthenticatorInterface.php @@ -16,8 +16,8 @@ interface BasicAuthenticatorInterface { * auth credentials. Calling this restores them so requests to sites behind * webserver-level basic auth stay authenticated after a reset. * - * Credentials come from the 'base_url' userinfo. For a driver that cannot - * set basic auth, such as a JavaScript driver, the call is a no-op. + * Credentials come from the 'base_url' userinfo. For a browser driver that + * cannot set basic auth, such as a JavaScript one, the call is a no-op. */ public function applyBasicAuth(): void; diff --git a/src/Behat/Manager/ScenarioTagRegistry.php b/src/Behat/Manager/ScenarioTagRegistry.php index a23a65dff..e04b56f66 100644 --- a/src/Behat/Manager/ScenarioTagRegistry.php +++ b/src/Behat/Manager/ScenarioTagRegistry.php @@ -7,10 +7,6 @@ /** * Holds the tags the running scenario carries. * - * 'BackendListener' fills it on 'ScenarioTested::BEFORE', which Behat - * dispatches before the first 'BeforeScenario' hook, so every option read - * within the scenario sees the same tags. - * * @see \DrevOps\BehatSteps\Behat\Listener\BackendListener */ class ScenarioTagRegistry implements ScenarioTagRegistryInterface { diff --git a/src/Behat/Mink/Adapter/BrowserKitAdapter.php b/src/Behat/Mink/Adapter/BrowserKitAdapter.php index 11e89e344..28aa22bf8 100644 --- a/src/Behat/Mink/Adapter/BrowserKitAdapter.php +++ b/src/Behat/Mink/Adapter/BrowserKitAdapter.php @@ -16,8 +16,8 @@ /** * Capabilities of a BrowserKit-based browser driver. * - * The browser driver is itself an HTTP client rather than a browser, so it - * reads cookies, sets request headers and lends out its client, but runs no + * The browser driver is itself an HTTP client rather than a browser. It reads + * cookies, sets request headers and exposes its client, but runs no * JavaScript and dispatches no key events. */ class BrowserKitAdapter extends BrowserAdapterBase implements CookieCapabilityInterface, HttpClientCapabilityInterface, RequestHeaderCapabilityInterface { @@ -38,10 +38,9 @@ public function cookieGetAll(): array { $jar = $driver->getClient()->getCookieJar(); // The value list holds 1 entry per name, already resolved for the current - // URL by domain, path and secure flag. The cookie objects supply the - // remaining properties, and 'all()' flattens every domain and path - // together, so several objects can share a name: the one carrying the - // resolved value is the one that belongs to this URL. + // URL by domain, path and secure flag. 'all()' flattens every domain and + // path together, so several objects can share a name: the one carrying + // the resolved value is the one that belongs to this URL. $resolved = $jar->allValues($driver->getCurrentUrl(), TRUE); $cookies = []; diff --git a/src/Behat/Mink/BrowserAdapterBase.php b/src/Behat/Mink/BrowserAdapterBase.php index 1802817d3..db0a21905 100644 --- a/src/Behat/Mink/BrowserAdapterBase.php +++ b/src/Behat/Mink/BrowserAdapterBase.php @@ -7,7 +7,7 @@ use Behat\Mink\Driver\DriverInterface; /** - * Holds the browser driver an adapter speaks for. + * Holds the browser driver an adapter wraps. */ abstract class BrowserAdapterBase implements BrowserAdapterInterface { diff --git a/src/Behat/Mink/BrowserAdapterInterface.php b/src/Behat/Mink/BrowserAdapterInterface.php index 7c8e39c5d..9e018c19c 100644 --- a/src/Behat/Mink/BrowserAdapterInterface.php +++ b/src/Behat/Mink/BrowserAdapterInterface.php @@ -9,14 +9,14 @@ /** * Declares which capabilities a browser driver provides. * - * Browser drivers ship from other packages, so one cannot implement the + * Browser drivers are defined in other packages, so one cannot implement the * capability interfaces itself. An adapter implements them on its behalf and - * states which browser driver it speaks for. + * declares which browser driver it supports. */ interface BrowserAdapterInterface { /** - * Whether this adapter speaks for the given browser driver. + * Whether this adapter supports the given browser driver. * * @param \Behat\Mink\Driver\DriverInterface $driver * The browser driver a session is running. diff --git a/src/Behat/Mink/BrowserCapabilityResolver.php b/src/Behat/Mink/BrowserCapabilityResolver.php index 266693e84..5403a94d0 100644 --- a/src/Behat/Mink/BrowserCapabilityResolver.php +++ b/src/Behat/Mink/BrowserCapabilityResolver.php @@ -11,17 +11,17 @@ use DrevOps\BehatSteps\Behat\Mink\Adapter\Selenium2Adapter; /** - * Answers what a session's browser driver can do. + * Resolves the capabilities of a session's browser driver. * * Mirrors 'BackendRegistry::getBackendFor()' on the Drupal side: a step names - * the capability it needs and never a browser driver, so a project - * registering its own browser driver gets the shipped steps working as soon - * as it registers an adapter declaring that capability. + * the capability it needs and never a browser driver. A project registering + * its own browser driver therefore runs the shipped steps as soon as it + * registers an adapter declaring that capability. */ class BrowserCapabilityResolver { /** - * Adapter classes, in the order they are offered a browser driver. + * Adapter classes, in the order they are tried for a browser driver. * * @var array> */ @@ -32,7 +32,7 @@ class BrowserCapabilityResolver { ]; /** - * Adapters already built, keyed by the browser driver they speak for. + * Adapters already built, keyed by the browser driver they wrap. * * Keyed by the object rather than its id, because PHP reuses an object id * once the object it belonged to is collected. @@ -52,7 +52,7 @@ public function __construct() { * Registers an adapter class ahead of the shipped ones. * * @param class-string<\DrevOps\BehatSteps\Behat\Mink\BrowserAdapterInterface> $adapter - * The adapter class to offer a browser driver first. + * The adapter class to try first for a browser driver. */ public function registerAdapter(string $adapter): void { array_unshift($this->adapters, $adapter); @@ -89,8 +89,8 @@ public function resolve(DriverInterface $driver, string $capability): object { * Whether the given browser driver provides a capability. * * Pairs with 'resolve()' the way 'BackendRegistryInterface::hasCapability()' - * pairs with 'getBackendFor()': a step that degrades gracefully asks this, - * and a step that cannot proceed without the capability calls 'resolve()'. + * pairs with 'getBackendFor()': this fits a capability the caller can do + * without, and 'resolve()' one it cannot. * * @param \Behat\Mink\Driver\DriverInterface $driver * The browser driver the session is running. @@ -102,7 +102,7 @@ public function has(DriverInterface $driver, string $capability): bool { } /** - * Returns the adapter speaking for a browser driver, or NULL when none does. + * Returns the adapter for a browser driver, or NULL when none supports it. * * @param \Behat\Mink\Driver\DriverInterface $driver * The browser driver the session is running. diff --git a/src/Behat/Mink/Capability/CookieCapabilityInterface.php b/src/Behat/Mink/Capability/CookieCapabilityInterface.php index dce04a300..38c094936 100644 --- a/src/Behat/Mink/Capability/CookieCapabilityInterface.php +++ b/src/Behat/Mink/Capability/CookieCapabilityInterface.php @@ -12,14 +12,14 @@ interface CookieCapabilityInterface { /** * Returns every cookie the browser currently holds, 1 per name. * - * Values come back in wire form, exactly as the browser stores them, so a - * caller building a 'Cookie' header passes them straight through and a - * caller asserting on a value decodes first. + * Values are in wire form, exactly as the browser stores them, so they fit + * a 'Cookie' header as is and need decoding before an assertion. * - * The name and the value are the whole contract. A BrowserKit cookie jar - * holds several objects per name across domains and paths and exposes only - * the resolved value for a URL, not the object it resolved, so any further - * attribute would be reliable on some browser drivers and a guess on others. + * The name and the value are the whole contract, because any further + * attribute would be reliable on some browser drivers and a guess on + * others. A BrowserKit cookie jar holds several objects per name across + * domains and paths and exposes only the value resolved for a URL, not the + * object. * * @return array * One entry per cookie name. diff --git a/src/Behat/Mink/Capability/HttpClientCapabilityInterface.php b/src/Behat/Mink/Capability/HttpClientCapabilityInterface.php index 7f3097846..6e44be928 100644 --- a/src/Behat/Mink/Capability/HttpClientCapabilityInterface.php +++ b/src/Behat/Mink/Capability/HttpClientCapabilityInterface.php @@ -7,10 +7,10 @@ use Symfony\Component\BrowserKit\AbstractBrowser; /** - * Capability: lend out the browser the Mink session drives. + * Capability: expose the browser the Mink session drives. * * A request sent through that browser becomes the page the session holds. A - * browser driver speaking to a real browser has no such browser in PHP, so + * browser driver controlling a real browser has no such browser in PHP, so * only a browser driver that is itself an HTTP client provides this. * * @see \DrevOps\BehatSteps\Behat\Context\WebRawContext::httpPageClient() diff --git a/src/Behat/Mink/Capability/JavascriptCapabilityInterface.php b/src/Behat/Mink/Capability/JavascriptCapabilityInterface.php index 55af4536f..c372c5db9 100644 --- a/src/Behat/Mink/Capability/JavascriptCapabilityInterface.php +++ b/src/Behat/Mink/Capability/JavascriptCapabilityInterface.php @@ -8,10 +8,11 @@ * Capability: the browser evaluates JavaScript. * * The interface declares no methods. A step executes its script through the - * Mink session's own API, so the only question an adapter answers here is - * whether the browser driver runs JavaScript at all; adding 'execute' and - * 'evaluate' methods no caller would use would repeat the unused-capability - * problem this layer exists to remove. + * Mink session's own API, so an adapter only states whether the browser + * driver runs JavaScript at all. + * + * 'execute' and 'evaluate' methods would have no caller, so they would add + * the unused-capability problem this layer removes. */ interface JavascriptCapabilityInterface { diff --git a/src/Behat/Mink/Capability/RequestHeaderCapabilityInterface.php b/src/Behat/Mink/Capability/RequestHeaderCapabilityInterface.php index 66eca796c..97d41f705 100644 --- a/src/Behat/Mink/Capability/RequestHeaderCapabilityInterface.php +++ b/src/Behat/Mink/Capability/RequestHeaderCapabilityInterface.php @@ -8,7 +8,7 @@ * Capability: set a header the browser sends with each request. * * A WebDriver session cannot add request headers, so a browser driver that - * speaks WebDriver does not provide this. + * uses WebDriver does not provide this. */ interface RequestHeaderCapabilityInterface { diff --git a/src/Behat/Mink/Element/DocumentElement.php b/src/Behat/Mink/Element/DocumentElement.php index 616fb256d..aa2fcc056 100644 --- a/src/Behat/Mink/Element/DocumentElement.php +++ b/src/Behat/Mink/Element/DocumentElement.php @@ -10,12 +10,12 @@ /** * Document element that reads page text the way a browser renders it. * - * Registered as a 'class_alias' over Mink's own 'DocumentElement', so it must - * extend 'TraversableElement' rather than that class: the alias is installed - * before the Mink class is autoloaded. + * Registered as a 'class_alias' over Mink's own 'DocumentElement'. The alias + * is installed before the Mink class is autoloaded, so this class extends + * 'TraversableElement' and not the class it replaces. * - * Under BrowserKit, Mink reads the text of the '//html' node, which counts the - * contents of '' and Drupal's settings JSON as page text, and throws + * Under BrowserKit, Mink reads the text of the '//html' node, so the contents + * of '' and Drupal's settings JSON count as page text. It also throws * outright on a response that is not HTML. * * @see https://github.com/minkphp/MinkBrowserKitDriver/issues/153 diff --git a/src/Behat/Mink/ServiceContainer/Driver/BrowserKitFactory.php b/src/Behat/Mink/ServiceContainer/Driver/BrowserKitFactory.php index d27bed63d..76c10f5c7 100644 --- a/src/Behat/Mink/ServiceContainer/Driver/BrowserKitFactory.php +++ b/src/Behat/Mink/ServiceContainer/Driver/BrowserKitFactory.php @@ -61,7 +61,7 @@ public function getClientOptions(): array { } /** - * Sorts options by key at every level, so key order never tells 2 apart. + * Sorts options by key at every level, so comparisons ignore key order. * * @param array $options * The options to sort. diff --git a/src/Behat/MinkAwareTrait.php b/src/Behat/MinkAwareTrait.php index 400601d58..a45ead9d0 100644 --- a/src/Behat/MinkAwareTrait.php +++ b/src/Behat/MinkAwareTrait.php @@ -49,8 +49,8 @@ public function getMink(): Mink { * Returns the Mink session. * * @param string|null $name - * The name of the session to return. If omitted the active session will - * be returned. + * The name of the session to return. If omitted, the active session is + * returned. */ public function getSession(?string $name = NULL): Session { return $this->getMink()->getSession($name); @@ -96,8 +96,8 @@ public function setMinkParameter(string $name, mixed $value): void { * 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. + * The name of the session to return. If omitted, the active session is + * returned. */ public function assertSession(?string $name = NULL): WebAssert { return $this->getMink()->assertSession($name); @@ -113,7 +113,8 @@ public function visitPath(string $path, ?string $session_name = NULL): void { /** * Locates a URL, based on the provided path. * - * Override to provide a custom routing mechanism. + * A class composing this trait overrides this method to provide a custom + * routing mechanism. */ public function locatePath(string $path): string { // Only a full 'http://' or 'https://' scheme makes the path absolute, so diff --git a/src/Behat/ParametersTrait.php b/src/Behat/ParametersTrait.php index 74d98c7c7..75e1cd8dc 100644 --- a/src/Behat/ParametersTrait.php +++ b/src/Behat/ParametersTrait.php @@ -13,9 +13,11 @@ * * Any context reads parameters, text and selectors through this trait, whether * or not it extends 'WebRawContext'. A context implements - * 'ParametersAwareInterface' and composes this trait; 'BackendAwareInitializer' - * then injects the parameter array through 'setParameters()' before any - * scenario runs. No backend bootstrap is required. + * 'ParametersAwareInterface' and composes this trait. + * + * 'BackendAwareInitializer' then injects the parameter array through + * 'setParameters()' before any scenario runs. No backend bootstrap is + * required. * * @see \DrevOps\BehatSteps\Behat\ServiceContainer\BehatStepsExtension */ @@ -65,12 +67,12 @@ public function getParameter(string $name): mixed { * The text value. * * @throws \RuntimeException - * Thrown when the text is not present in the list of parameters. + * 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)); + throw new \RuntimeException(sprintf('No such Drupal string: %s.', $name)); } return $text[$name]; @@ -86,12 +88,12 @@ public function getDrupalText(string $name): string { * The CSS selector. * * @throws \RuntimeException - * Thrown when the selector is not present in the list of parameters. + * 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)); + throw new \RuntimeException(sprintf('No such selector configured: %s.', $name)); } return $selectors[$name]; diff --git a/src/Behat/Prerequisite/Prerequisite.php b/src/Behat/Prerequisite/Prerequisite.php index eba82386f..8597e176f 100644 --- a/src/Behat/Prerequisite/Prerequisite.php +++ b/src/Behat/Prerequisite/Prerequisite.php @@ -7,10 +7,11 @@ /** * One prerequisite a trait declares. * - * A prerequisite is stated through a backend capability: a backend in the - * scenario's list provides the capability, and for a check, that backend passes - * the check. A trait returns its prerequisites from a 'Prerequisites()' - * method. + * A prerequisite is stated through a backend capability. A backend in the + * scenario's list provides the capability, and for a check, that backend + * passes the check. + * + * A trait returns its prerequisites from a 'Prerequisites()' method. * * @see \DrevOps\BehatSteps\Behat\Prerequisite\PrerequisiteReader */ @@ -110,7 +111,7 @@ public static function capability(string $capability, string $description = ''): * Declares a check a backend providing a capability passes. * * The capability is the type of the closure's only parameter, so it cannot - * drift from what the closure calls. + * differ from what the closure calls. * * @param \Closure $check * A static closure taking the backend, typed to the capability interface it diff --git a/src/Behat/Selector/RegionSelector.php b/src/Behat/Selector/RegionSelector.php index 02bdf6552..5a40337d0 100644 --- a/src/Behat/Selector/RegionSelector.php +++ b/src/Behat/Selector/RegionSelector.php @@ -19,7 +19,7 @@ class RegionSelector implements SelectorInterface { * Constructs a RegionSelector. * * @param \Behat\Mink\Selector\CssSelector $cssSelector - * The CSS selector that performs the actual CSS-to-XPath translation. + * The CSS selector that translates CSS to XPath. * @param array $regions * Map of region names to CSS selectors, sourced from the extension's * 'regions' configuration. @@ -45,7 +45,7 @@ public function __construct( // phpcs:ignore Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps public function translateToXPath($locator): string { if (!is_string($locator) || !isset($this->regions[$locator])) { - throw new \RuntimeException(sprintf('The "%s" region isn\'t configured!', is_string($locator) ? $locator : gettype($locator))); + throw new \RuntimeException(sprintf('The "%s" region is not configured.', is_string($locator) ? $locator : get_debug_type($locator))); } return $this->cssSelector->translateToXPath($this->regions[$locator]); diff --git a/src/Behat/ServiceContainer/BehatStepsExtension.php b/src/Behat/ServiceContainer/BehatStepsExtension.php index 6ac79dd74..46ed22ef7 100644 --- a/src/Behat/ServiceContainer/BehatStepsExtension.php +++ b/src/Behat/ServiceContainer/BehatStepsExtension.php @@ -278,9 +278,8 @@ protected function loadParameters(ContainerBuilder $container, array $config): v $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 the configuration. + // Mirror the map into the config so 'behat_steps.parameters' and + // 'behat_steps.regions' expose the same value when 'regions' is omitted. $config['regions'] = $regions; $container->setParameter('behat_steps.parameters', $config); @@ -416,7 +415,7 @@ public static function resolveBinaryPath(string $binary): string { return $candidate; } - // Probe the parent directory, which covers a working directory one level + // Probe the parent directory, which covers a working directory 1 level // deep such as a Drupal root inside a project. $candidate = dirname($cwd) . '/' . $binary; if (file_exists($candidate)) { @@ -477,9 +476,9 @@ protected function processBackends(ContainerBuilder $container): void { foreach ($backends as $tag => $name) { $tag = $this->validateBackendEntry($tag, $name, $registered); - // Resolution lowercases a name, so two entries differing only by case - // would collapse into one and the later would silently take the - // earlier's place in the order. + // Resolution lowercases a name, so 2 entries differing only by case + // would collapse into 1 and the later would silently replace the + // earlier in the order. if (isset($seen[$tag])) { throw new InvalidConfigurationException(sprintf('The "backends" list under "%s" names "%s" twice. A name is matched without regard to case, so it may appear only once.', self::CONFIG_KEY, $tag)); } @@ -513,8 +512,8 @@ protected function validateBackendEntry(int|string $tag, mixed $name, array $reg $tag = strtolower(is_int($tag) ? $name : $tag); // A tag name is typed into a feature file after '@backend:', so it cannot - // carry whitespace or a second colon. '\z' rather than '$', which would - // also match before a trailing newline and let one through. + // carry whitespace or a second colon. '$' would also match before a + // trailing newline, so the pattern ends in '\z'. if (preg_match('/^[a-z0-9_-]+\z/', $tag) !== 1) { throw new InvalidConfigurationException(sprintf('The "backends" list under "%s" names a backend "%s". A backend name may hold only letters, digits, "_" and "-", so that "@backend:%s" is a valid tag.', self::CONFIG_KEY, $tag, $tag)); } @@ -549,8 +548,8 @@ protected function processHttpClient(ContainerBuilder $container): void { * Switches to the 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. + * 'process()' runs, as references to a service id. Replacing the definition + * behind that id swaps the class in place. */ protected function processClassGenerator(ContainerBuilder $container): void { $definition = new Definition(ClassGenerator::class); diff --git a/src/Behat/Tag.php b/src/Behat/Tag.php index 5bfeb8204..4846373a4 100644 --- a/src/Behat/Tag.php +++ b/src/Behat/Tag.php @@ -15,10 +15,12 @@ * 'TaggedNodeInterface::hasTag()' compares strictly. Every tag this library * reads goes through here, so a tag matches on both majors. * - * 'has()', 'values()' and 'valueStates()' take a subject: a scenario scope or - * event reads the scenario together with its feature, and a node reads that - * node alone. A parametrized tag reads '@:', and a '!' before - * the value switches it off, as in '@module:!help'. + * 'has()', 'values()' and 'valueStates()' take a subject. A scenario scope + * or event reads the scenario together with its feature, and a node reads + * that node alone. + * + * A parametrized tag reads '@:', and a '!' before the value + * switches it off, as in '@module:!help'. */ final class Tag { diff --git a/src/Helper/Drupal/AuthTrait.php b/src/Helper/Drupal/AuthTrait.php index dda9f60b6..6b0716881 100644 --- a/src/Helper/Drupal/AuthTrait.php +++ b/src/Helper/Drupal/AuthTrait.php @@ -54,9 +54,9 @@ trait AuthTrait { /** * Removes any created users. * - * The early-return guard also skips the logout below, because * 'BEHAT_STEPS_DISABLE_CLEANUP' leaves the failing scenario's state intact, - * session included. Later scenarios in the same run inherit that login. + * session included, so the early-return guard skips the logout as well. + * Later scenarios in the same run inherit that login. */ #[AfterScenario] public function authCleanUsers(AfterScenarioScope $scope): void { @@ -82,9 +82,9 @@ public function authCleanUsers(AfterScenarioScope $scope): void { $user_registry->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. + // A scenario can log in as a pre-existing user without calling + // userCreate(), so the auth state is reset even when it created no users. + // Otherwise the next scenario starts with stale session state. if ($this->authGetAuthenticator() instanceof FastLogoutInterface) { $this->authLogout(TRUE); } @@ -178,8 +178,8 @@ public function authUserCreate(EntityStubInterface $stub): EntityStubInterface { $backend->userCreate($stub); $this->entityLifecycleRestoreScalarBaseFields($stub, $scalars); - // Register before the post-create hooks run: a hook that throws still - // leaves the user behind, and cleanup removes only registered stubs. + // Cleanup removes only registered stubs. A post-create hook that throws + // leaves the saved user in place, so the stub is registered first. $this->authGetUserRegistry()->addUser($stub); $this->entityLifecycleDispatchHooks(AfterUserCreateScope::class, $stub); diff --git a/src/Helper/Drupal/EntityLifecycleTrait.php b/src/Helper/Drupal/EntityLifecycleTrait.php index d203ba533..998927cd1 100644 --- a/src/Helper/Drupal/EntityLifecycleTrait.php +++ b/src/Helper/Drupal/EntityLifecycleTrait.php @@ -42,7 +42,7 @@ trait EntityLifecycleTrait { /** - * The tag that keeps the entities of the type it names after the scenario. + * The tag that names an entity type excluded from cleanup. */ protected const ENTITY_LIFECYCLE_CLEANUP_SKIP_TAG = 'behat-steps-entity-cleanup-skip'; @@ -63,12 +63,11 @@ trait EntityLifecycleTrait { * When a timestamp value cannot be read as a date. */ #[BeforeNodeCreate] - public static function entityLifecycleAlterNodeParameters(BeforeNodeCreateScope $scope): void { + public static function entityLifecycleBeforeNodeCreate(BeforeNodeCreateScope $scope): void { $stub = $scope->getStub(); - // A backend that writes the node over the command line takes the values as - // written, so string dates are converted only for a backend that saves them - // through Drupal's own storage. + // A command-line backend takes the values as written, so string dates are + // converted only for a backend that saves through Drupal's own storage. $context = $scope->getContext(); if (!$context instanceof BackendAwareInterface) { @@ -109,11 +108,11 @@ public static function entityLifecycleAlterNodeParameters(BeforeNodeCreateScope * node referencing a term is deleted before the entity it references. * * '@behat-steps-skip:EntityLifecycleTrait' skips the whole pass, and - * '@behat-steps-entity-cleanup-skip:' skips one entity + * '@behat-steps-entity-cleanup-skip:' skips 1 entity * type. */ #[AfterScenario] - public function entityLifecycleCleanAll(AfterScenarioScope $scope): void { + public function entityLifecycleAfterScenario(AfterScenarioScope $scope): void { if (!$this->shouldCleanup() || $this->skipTag(__TRAIT__, $scope)) { return; } @@ -215,7 +214,7 @@ public function entityLifecycleTermCreate(EntityStubInterface $stub): EntityStub /** * Creates an entity of a type that has no dedicated method. * - * The stub is added to 'createdStubs', so 'entityLifecycleCleanAll()' + * The stub is added to 'createdStubs', so 'entityLifecycleAfterScenario()' * removes it after the scenario through the backend's 'entityDelete()' * fallback. * @@ -381,7 +380,7 @@ protected function entityLifecycleSkippedCleanupTypes(ScenarioScope $scope): arr * When the context has not been initialized by Behat. */ protected function entityLifecycleDispatchHooks(string $scope_class, EntityStubInterface $stub): void { - if (!$this->dispatcher instanceof HookDispatcher) { + if (!$this->hookDispatcher instanceof HookDispatcher) { throw new \RuntimeException('The hook dispatcher is available only after Behat has initialized the context.'); } @@ -392,10 +391,8 @@ protected function entityLifecycleDispatchHooks(string $scope_class, EntityStubI } $scope = new $scope_class($environment, $this, $stub); - $call_results = $this->dispatcher->dispatchScopeHooks($scope); + $call_results = $this->hookDispatcher->dispatchScopeHooks($scope); - // The dispatcher collects exceptions rather than raising them, so the - // first one is rethrown here. foreach ($call_results as $call_result) { $exception = $call_result->getException(); @@ -452,7 +449,7 @@ protected function entityLifecycleGetFieldParser(string $entity_type, FieldClass * * 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 backend to surface a not-found error. + * when no label matches, so the backend reports a not-found error. */ protected function entityLifecycleResolveVocabularyMachineName(string $identifier): string { $this->backendFor(CoreCapabilityInterface::class); @@ -473,10 +470,10 @@ protected function entityLifecycleResolveVocabularyMachineName(string $identifie /** * Captures the scalar values on an entity stub. * - * The backend 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 expects scalars, so the - * values are captured before the backend call and restored after it. + * During create, the backend's field-handler pipeline casts scalar + * base-field values such as 'title', 'name', 'mail' or 'pass' to + * single-element arrays. Downstream code expects scalars, so the values are + * captured before the backend call and restored after it. * * @param \DrevOps\BehatSteps\Backend\Entity\EntityStubInterface $stub * The entity stub to inspect. diff --git a/src/Helper/Drupal/FixtureFileTrait.php b/src/Helper/Drupal/FixtureFileTrait.php index 666874e18..b5e132af4 100644 --- a/src/Helper/Drupal/FixtureFileTrait.php +++ b/src/Helper/Drupal/FixtureFileTrait.php @@ -60,8 +60,8 @@ public function fixtureFileExpandEntityFields(string $entity_type, EntityStubInt continue; } - // A stub not yet parsed by 'entityParseFields()' still holds the raw - // compound cell as written in the Behat table + // A stub not yet parsed by 'entityLifecycleParseFields()' still holds + // the raw compound cell as written in the Behat table // (e.g. 'target_id:"foo.jpg", alt:"A"'). if (is_string($value) && $this->fixtureFileLooksLikeCompoundCell($value)) { $rewritten = $this->fixtureFileExpandCompoundCell($value, $fixture_path); @@ -73,16 +73,13 @@ public function fixtureFileExpandEntityFields(string $entity_type, EntityStubInt continue; } - // Parsed shapes produced by 'EntityFieldParser' or the legacy parser: + // The remaining shapes a stub value takes: // - scalar: 'foo.jpg' (treated as single-value) // - scalar list: ['foo.jpg', 'bar.jpg'] (multi-value) - // - keyed record: ['target_id' => 'foo.jpg', 'alt' => 'A'] (single compound) - // - list of records: [['target_id' => 'foo.jpg', 'alt' => 'A'], ...] (multi-value compound) - // - // Numerically-indexed arrays (lists) are iterated element-by-element so - // every delta is resolved. Keyed records and bare scalars are wrapped - // in a single-element list, processed once, and unwrapped when written - // back to the stub. + // - keyed record: ['target_id' => 'foo.jpg', 'alt' => 'A'] + // (single compound) + // - list of records: [['target_id' => 'foo.jpg', 'alt' => 'A'], ...] + // (multi-value compound) $is_list = is_array($value) && array_is_list($value); $records = $is_list ? $value : [$value]; $mutated = FALSE; diff --git a/src/Helper/Drupal/StaticCacheTrait.php b/src/Helper/Drupal/StaticCacheTrait.php index a3c365a0b..1d5378a87 100644 --- a/src/Helper/Drupal/StaticCacheTrait.php +++ b/src/Helper/Drupal/StaticCacheTrait.php @@ -22,7 +22,7 @@ trait StaticCacheTrait { * static cache, so no backend is resolved for a scenario that did not. */ #[AfterScenario] - public function staticCacheClear(AfterScenarioScope $scope): void { + public function staticCacheAfterScenario(AfterScenarioScope $scope): void { $this->getBackendRegistry()->getResolvedBackendFor(CacheCapabilityInterface::class)?->cacheClearStatic(); } diff --git a/src/Helper/Web/RequestHeadersTrait.php b/src/Helper/Web/RequestHeadersTrait.php index af0490e00..8ddf27f81 100644 --- a/src/Helper/Web/RequestHeadersTrait.php +++ b/src/Helper/Web/RequestHeadersTrait.php @@ -7,9 +7,8 @@ /** * Holds the request headers shared by the traits that issue HTTP requests. * - * One array is shared by every composing trait, so a header set by one trait - * is available to the trait that sends the request whether or not the - * context composes both. + * The array is a property of the context object, so a header set through one + * composing trait is read by every other composing trait in the same object. */ trait RequestHeadersTrait { diff --git a/src/Helper/Web/TableTransposeTrait.php b/src/Helper/Web/TableTransposeTrait.php index 10a775d85..4dbcb9044 100644 --- a/src/Helper/Web/TableTransposeTrait.php +++ b/src/Helper/Web/TableTransposeTrait.php @@ -7,18 +7,18 @@ use Behat\Gherkin\Node\TableNode; /** - * Reads a vertical Gherkin table as one set of values per entity. + * Reads a vertical Gherkin table as 1 set of values per entity. * - * A vertical table names a field per row and carries one column of values - * per entity. It reads better than a wide horizontal table when an entity - * has many fields. + * A vertical table names a field per row and carries 1 column of values per + * entity. It reads better than a wide horizontal table when an entity has + * many fields. */ trait TableTransposeTrait { /** * Transpose a vertical table format (field/value columns) to entity arrays. * - * Supports both single and multiple entity creation: + * Supports both a single entity and multiple entities: * * Single entity (2 columns): * | name | John | @@ -81,7 +81,7 @@ public function tableTransposeVertical(TableNode $table): array { * Convert vertical format entities to horizontal TableNode. * * @param array> $entities - * Array of entity data arrays from transposeVerticalTable(). + * Array of entity data arrays from tableTransposeVertical(). * * @return \Behat\Gherkin\Node\TableNode * TableNode in horizontal format (first row is headers, subsequent rows diff --git a/src/Steps/Drupal/BatchTrait.php b/src/Steps/Drupal/BatchTrait.php index 9fab503c0..e597dc84b 100644 --- a/src/Steps/Drupal/BatchTrait.php +++ b/src/Steps/Drupal/BatchTrait.php @@ -9,7 +9,7 @@ /** * Wait for Drupal's Batch API to finish. * - * - Poll the batch progress element until it leaves the page. + * - Poll the batch progress element until the page no longer contains it. * * A batch page reloads itself until the operation completes, so a following * assertion would otherwise read the progress screen rather than the result. diff --git a/src/Steps/Drupal/BigPipeTrait.php b/src/Steps/Drupal/BigPipeTrait.php index aaff7612e..361d5d4cf 100644 --- a/src/Steps/Drupal/BigPipeTrait.php +++ b/src/Steps/Drupal/BigPipeTrait.php @@ -21,18 +21,19 @@ * replacements complete fails intermittently with "element not found". * * With this trait included, every `@javascript` scenario waits before each - * step until no BigPipe placeholder marker remains in the DOM, which removes - * that race without an explicit step. + * step until no BigPipe placeholder marker remains in the DOM. The wait + * removes the race without an explicit step. * * The wait is best-effort: on timeout the step still runs, so a placeholder * that is never replaced fails the following assertion rather than the wait. * * A browser driver that runs no JavaScript never replaces those placeholders, * and does not follow the `http-equiv=refresh` fallback either. An - * authenticated-user assertion on such a browser driver silently misses - * whatever BigPipe deferred. A scenario tagged `@bigpipe` gets the - * `big_pipe_nojs` cookie, which makes Drupal render the page in full - * server-side. + * authenticated-user assertion on such a browser driver silently misses the + * content BigPipe deferred. + * + * A scenario tagged `@bigpipe` gets the `big_pipe_nojs` cookie, so Drupal + * renders the page in full server-side. * * Skip processing with tag: `@behat-steps-skip:BigPipeTrait`. * @@ -40,7 +41,7 @@ * - `@bigpipe` - render server-side on a browser driver without JavaScript. * * Set the `big_pipe.wait_timeout` option to change the maximum wait, or assign - * `$bigPipeWaitTimeout` to override it for one scenario. + * `$bigPipeWaitTimeout` to override it for 1 scenario. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ @@ -65,7 +66,7 @@ trait BigPipeTrait { protected bool $bigPipeAutoWaitEnabled = FALSE; /** - * Whether the scenario asked for server-side rendering. + * Whether server-side rendering is enabled for the scenario. */ protected bool $bigPipeServerRenderEnabled = FALSE; @@ -101,7 +102,7 @@ public function bigPipeBeforeScenario(BeforeScenarioScope $scope): void { * rather than set once for the scenario. */ #[BeforeStep] - public function bigPipeWaitBeforeStep(BeforeStepScope $scope): void { + public function bigPipeBeforeStep(BeforeStepScope $scope): void { $this->bigPipeApplyServerRenderCookie(); if (!$this->bigPipeAutoWaitEnabled) { @@ -130,7 +131,7 @@ public function bigPipeWaitForPlaceholders(int $timeout_ms): void { } /** - * Set the no-JS cookie when the scenario asked for server-side rendering. + * Set the no-JS cookie for a scenario with server-side rendering enabled. * * A browser driver that runs JavaScript replaces the placeholders itself, so * the cookie is only for the ones that do not. 'setCookie()' is idempotent, @@ -141,8 +142,6 @@ protected function bigPipeApplyServerRenderCookie(): void { return; } - // The probe runs once a scenario: a browser driver does not gain or lose - // script support between steps. $this->bigPipeJavascriptProbe ??= $this->bigPipeJavascriptIsSupported(); if ($this->bigPipeJavascriptProbe === TRUE) { diff --git a/src/Steps/Drupal/BlockTrait.php b/src/Steps/Drupal/BlockTrait.php index 6dc00b480..793da7c2e 100644 --- a/src/Steps/Drupal/BlockTrait.php +++ b/src/Steps/Drupal/BlockTrait.php @@ -275,7 +275,7 @@ public function blockAssertNotExists(string $label): void { $block = $this->blockFindByLabel($label); if (!empty($block)) { - throw new ExpectationException(sprintf('The block "%s" exists but should not.', $label), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('The block "%s" exists, but it should not.', $label), $this->getSession()->getDriver()); } } @@ -329,7 +329,7 @@ public function blockAssertNotExistsInRegion(string $label, string $region): voi $actual_region = $block->getRegion(); if ($actual_region === $region) { - throw new ExpectationException(sprintf('Block "%s" is in region "%s" but should not be.', $label, $region), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('Block "%s" is in region "%s", but it should not be.', $label, $region), $this->getSession()->getDriver()); } } diff --git a/src/Steps/Drupal/CacheTrait.php b/src/Steps/Drupal/CacheTrait.php index e12dbfaa5..aff03e18f 100644 --- a/src/Steps/Drupal/CacheTrait.php +++ b/src/Steps/Drupal/CacheTrait.php @@ -132,9 +132,6 @@ public function cacheRunCron(): void { /** * Get the cache bin used for the page cache. - * - * A consuming `FeatureContext` overrides this method when the site uses a - * custom internal page cache bin name. */ public function cacheGetPageCacheBin(): string { return $this->getOptionString('cache', 'page_cache_bin'); diff --git a/src/Steps/Drupal/ConfigOverrideTrait.php b/src/Steps/Drupal/ConfigOverrideTrait.php index bdc6d90af..ee2ff11e6 100644 --- a/src/Steps/Drupal/ConfigOverrideTrait.php +++ b/src/Steps/Drupal/ConfigOverrideTrait.php @@ -20,16 +20,20 @@ * runtime. They cannot be disabled from the Behat process because tests run * in a separate process from the system under test (SUT). * - * This trait signals the SUT - through a request header, a `$_SERVER` entry - * and an environment variable - that specific config objects should be read - * from their original (unoverridden) values. The SUT is responsible for - * reading that signal and calling `ImmutableConfig::getOriginal()` instead of - * `ImmutableConfig::get()` for the listed config names. + * This trait signals the SUT that specific config objects should be read + * from their original (unoverridden) values. The signal is a request header, + * a `$_SERVER` entry and an environment variable. + * + * The SUT is responsible for reading that signal and calling + * `ImmutableConfig::getOriginal()` instead of `ImmutableConfig::get()` for + * the listed config names. * * Activated by adding `@disable-config-override:CONFIG_NAME` tags to a * feature or scenario. Multiple tags are combined into a comma-separated - * list. Runs on every step because some steps reset headers set earlier in - * the scenario. + * list. + * + * The signal is applied before every step because some steps reset headers + * set earlier in the scenario. * * Limitations: * - The request header reaches the SUT only on a browser driver providing @@ -51,9 +55,9 @@ * @endcode * * The signal is also written to the request-header bag, so a trait that - * issues its own HTTP requests - `RestTrait` - carries it too. The bag is - * per context, so that reaches `RestTrait` only where one context composes - * both; the shipped `WebContext` and `DrupalContext` are separate objects. + * issues its own HTTP requests - `RestTrait` - carries it too. The bag is a + * property of the context object, so the signal reaches `RestTrait` when the + * same context composes both traits, as the shipped `DrupalContext` does. * * Example: * @code @@ -112,9 +116,8 @@ public function configOverrideBeforeScenario(BeforeScenarioScope $scope): void { #[BeforeStep] public function configOverrideBeforeStep(BeforeStepScope $scope): void { if ($this->configOverrideDisabledNames === []) { - // Nothing to propagate. The process-level signal persists beyond the - // scenario that set it, so it is cleared here along with the browser - // driver's header. + // The process-level signal persists beyond the scenario that set it, so + // it is cleared here along with the browser driver's header. $this->configOverrideClearSignal(); $this->configOverrideClearBrowserDriverHeader(); diff --git a/src/Steps/Drupal/ConfigTrait.php b/src/Steps/Drupal/ConfigTrait.php index d83e1959a..48bcf02db 100644 --- a/src/Steps/Drupal/ConfigTrait.php +++ b/src/Steps/Drupal/ConfigTrait.php @@ -22,13 +22,12 @@ * object's key holds, or contains, an expected value. Nested keys are * addressable with dotted notation (for example `page.front`). * - * Two families of assertions read the value differently: - * - The default steps read the STORED value via editable configuration, - * ignoring `settings.php` overrides. This is symmetric with the set steps - * and is what most setup-and-assert scenarios need. - * - The `effective` steps read the value through the config factory with - * module and `settings.php` overrides applied - the value the running site - * actually uses. + * 2 families of assertions read the value differently: + * - The default steps read the stored value, with module and `settings.php` + * overrides left unapplied. This is symmetric with the set steps and is + * what most setup-and-assert scenarios need. + * - The `effective` steps read the value with module and `settings.php` + * overrides applied: the value the running site uses. * * Values are compared by their stringified form, so `true`, `42` and JSON * arrays written in a step match their typed configuration counterparts. The @@ -36,8 +35,8 @@ * array values, searched recursively. * * Configuration objects touched by the set steps are snapshotted on first - * write and restored after the scenario: an existing object is reset to its - * original data and an object that did not exist is deleted. Skip the revert + * write and restored after the scenario. An existing object is reset to its + * original data, and an object that did not exist is deleted. Skip the revert * with `@behat-steps-skip:ConfigTrait`. * * @code @@ -245,9 +244,6 @@ public function configAssertEffectiveValueNotContains(string $name, string $key, /** * Read a stored configuration value, ignoring runtime overrides. * - * Editable configuration objects never carry module or `settings.php` - * overrides, so reading through one yields the value as saved. - * * @param string $name * The configuration object name. * @param string $key @@ -367,7 +363,7 @@ protected function configAssertContains(mixed $actual, string $expected, bool $s } if ($contains) { - throw new AssertionException(sprintf('The config "%s" key "%s" has the %s "%s", which contains "%s" but should not.', $name, $key, $descriptor, $actual_string, $expected)); + throw new AssertionException(sprintf('The config "%s" key "%s" has the %s "%s", which contains "%s", but it should not.', $name, $key, $descriptor, $actual_string, $expected)); } } diff --git a/src/Steps/Drupal/ContentBlockTrait.php b/src/Steps/Drupal/ContentBlockTrait.php index 3a94d188d..d69280a51 100644 --- a/src/Steps/Drupal/ContentBlockTrait.php +++ b/src/Steps/Drupal/ContentBlockTrait.php @@ -155,7 +155,7 @@ public function contentBlockAssertTypeExists(string $content_block_type): void { $block_content_type = \Drupal::entityTypeManager()->getStorage('block_content_type')->load($content_block_type); if (!$block_content_type instanceof BlockContentTypeInterface) { - throw new ExpectationException(sprintf('Content block type "%s" does not exist.', $content_block_type), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('The content block type "%s" does not exist.', $content_block_type), $this->getSession()->getDriver()); } } @@ -165,7 +165,7 @@ public function contentBlockAssertTypeExists(string $content_block_type): void { * Created entities are registered with the shared entity registry so that * they are removed at the end of the scenario. * - * @param string $type + * @param string $content_block_type * The machine name of the block content type. * @param array $values * Associative array of field values for the content block entity. @@ -180,11 +180,11 @@ public function contentBlockAssertTypeExists(string $content_block_type): void { * @throws \Drupal\Core\Entity\EntityStorageException * When the entity cannot be saved. */ - public function contentBlockCreateSingle(string $type, array $values): BlockContent { + public function contentBlockCreateSingle(string $content_block_type, array $values): BlockContent { $this->backendFor(CoreCapabilityInterface::class); - $values['type'] = $type; - $stub = new EntityStub('block_content', $type, $values); + $values['type'] = $content_block_type; + $stub = new EntityStub('block_content', $content_block_type, $values); $this->entityLifecycleParseFields($stub); /** @var \Drupal\block_content\Entity\BlockContent $entity */ @@ -199,7 +199,7 @@ public function contentBlockCreateSingle(string $type, array $values): BlockCont /** * Load multiple content blocks with specified type and conditions. * - * @param string $type + * @param string $content_block_type * The block content type. * @param array $conditions * Conditions keyed by field names. @@ -208,8 +208,8 @@ public function contentBlockCreateSingle(string $type, array $values): BlockCont * The matching content blocks keyed by ID, or an empty array when none * match. */ - public function contentBlockLoadMultiple(string $type, array $conditions = []): array { - $ids = $this->queryEntityIds('block_content', $conditions, $type); + public function contentBlockLoadMultiple(string $content_block_type, array $conditions = []): array { + $ids = $this->queryEntityIds('block_content', $conditions, $content_block_type); return $ids ? BlockContent::loadMultiple($ids) : []; } diff --git a/src/Steps/Drupal/ContentTrait.php b/src/Steps/Drupal/ContentTrait.php index eff5df7fd..3642e41b5 100644 --- a/src/Steps/Drupal/ContentTrait.php +++ b/src/Steps/Drupal/ContentTrait.php @@ -44,9 +44,9 @@ */ trait ContentTrait { - use QueryTrait; use EntityLifecycleTrait; use FixtureFileTrait; + use QueryTrait; use TableTransposeTrait; /** @@ -241,7 +241,7 @@ public function contentChangeModerationStateWithTitle(string $content_type, stri * @endcode */ #[When('I rebuild the access grants for the :content_type content with the title :title')] - public function contentRebuildAccessGrantsByTitle(string $content_type, string $title): void { + public function contentRebuildAccessGrantsWithTitle(string $content_type, string $title): void { $this->backendFor(CoreCapabilityInterface::class); $node = $this->contentGetNodeByTitle($content_type, $title); @@ -401,7 +401,7 @@ public function contentGetNidByTitle(string $content_type, string $title): int { $content_type_entity = \Drupal::entityTypeManager()->getStorage('node_type')->load($content_type); if (!$content_type_entity) { - throw new \RuntimeException(sprintf('Content type "%s" does not exist.', $content_type)); + throw new \RuntimeException(sprintf('The content type "%s" does not exist.', $content_type)); } $nids = $this->queryNodeIds($content_type, [ diff --git a/src/Steps/Drupal/DraggableviewsTrait.php b/src/Steps/Drupal/DraggableviewsTrait.php index c27e56b55..cd5a94811 100644 --- a/src/Steps/Drupal/DraggableviewsTrait.php +++ b/src/Steps/Drupal/DraggableviewsTrait.php @@ -25,7 +25,7 @@ trait DraggableviewsTrait { use QueryTrait; /** - * Save order of the Draggable Order items. + * Save the order of the Draggable Views items. * * @code * When I save the draggable views items of the view "draggableviews_demo" and the display "page_1" for the "article" content in the following order: diff --git a/src/Steps/Drupal/DrushTrait.php b/src/Steps/Drupal/DrushTrait.php index c64a8e0e1..88e218acf 100644 --- a/src/Steps/Drupal/DrushTrait.php +++ b/src/Steps/Drupal/DrushTrait.php @@ -16,9 +16,9 @@ * - Run a command that is expected to fail and keep its output. * - Assert the last command's output by substring or regular expression. * - * Steps resolve the backend that can run Drush commands rather than the one at - * the front of the scenario's order, so they work in a scenario driven by any - * other backend as long as the suite lists a Drush-capable one. + * Steps resolve the backend that can run Drush commands, not the first one in + * the scenario's order. They work in a scenario driven by any other backend + * as long as the suite lists a Drush-capable one. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ @@ -137,8 +137,8 @@ public function drushAssertOutputMatches(string $pattern): void { $output = $this->drushReadOutput(); $result = @preg_match($pattern, $output); - // A malformed pattern also returns FALSE, so it is reported apart from an - // output that did not match. + // 'preg_match()' returns FALSE for a malformed pattern, so that case is + // reported apart from an output that did not match. if ($result === FALSE) { throw new \RuntimeException(sprintf('"%s" is not a valid regular expression: %s.', $pattern, preg_last_error_msg())); } @@ -187,8 +187,8 @@ public function drushRunExpectingFailure(string $command, ?string $arguments = N $args = $arguments === NULL ? [] : [$this->drushFixArgument($arguments)]; $result = $this->drushGetBackend()->drushResult($command, $args); - // Prefer stdout and fall back to stderr. The success path returns whatever - // the command wrote, and the failure path matches it. + // The success path returns the command's output, so the failure path keeps + // stdout and falls back to stderr when it is empty. $output = $result->output === '' ? $result->errorOutput : $result->output; $this->drushOutput = $output; diff --git a/src/Steps/Drupal/EckTrait.php b/src/Steps/Drupal/EckTrait.php index aaa2e09e4..211ad02fb 100644 --- a/src/Steps/Drupal/EckTrait.php +++ b/src/Steps/Drupal/EckTrait.php @@ -27,8 +27,8 @@ */ trait EckTrait { - use QueryTrait; use EntityLifecycleTrait; + use QueryTrait; /** * Create eck entities. diff --git a/src/Steps/Drupal/EmailTrait.php b/src/Steps/Drupal/EmailTrait.php index fefc28e5b..039d812b6 100644 --- a/src/Steps/Drupal/EmailTrait.php +++ b/src/Steps/Drupal/EmailTrait.php @@ -92,8 +92,8 @@ public function emailAfterScenario(AfterScenarioScope $scope): void { return; } - // The step can enable the system without the '@email' tag, so the enabled - // handler types decide the teardown. + // The step can enable the system without the '@email' tag, so the teardown + // checks the enabled handler types instead. if ($this->emailHandlerTypes === []) { return; } @@ -178,8 +178,8 @@ public function emailFollowLinkContaining(string $partial_url): void { foreach ($this->emailGetCollectedMessages() as $message) { $body = $message['params']['body'] ?? NULL; - // A handler that puts a structure in 'params.body' leaves the rendered - // text in 'body', so fall through rather than skipping the message. + // A handler that stores a structure in 'params.body' leaves the rendered + // text in 'body', so 'body' is read in that case. if (!is_string($body)) { $body = $message['body'] ?? ''; } @@ -252,10 +252,10 @@ public function emailFollowLinkNumberWithSubjectContaining(string $index, string /** * Enable the test email system. * - * Collects with the handler types the scenario's `@email:TYPE` tags name, or - * with the `default` handler when none do. The system is disabled again once - * the scenario finishes, unless `@behat-steps-skip:EmailTrait` switches the - * trait's hooks off. + * Collects with the handler types named by the scenario's `@email:TYPE` tags, + * or with the `default` handler when none are named. The system is disabled + * again once the scenario finishes, unless `@behat-steps-skip:EmailTrait` + * switches the trait's hooks off. * * @code * When I enable the test email system @@ -275,8 +275,7 @@ public function emailEnableTestSystem(): void { $this->emailSetMailSystemDefault($type, 'test_mail_collector'); } - // Clearing here lets this step definition be reused to clear existing - // mail. + // Clearing on enable lets this step also reset existing mail. $this->emailClearTestQueue(TRUE); } diff --git a/src/Steps/Drupal/EntityTrait.php b/src/Steps/Drupal/EntityTrait.php index 427cd70c7..2b068c048 100644 --- a/src/Steps/Drupal/EntityTrait.php +++ b/src/Steps/Drupal/EntityTrait.php @@ -19,7 +19,7 @@ * created here are removed after the scenario along with every other entity * the scenario created. * - * Skip cleanup for one type with tag: + * Skip cleanup for 1 type with tag: * `@behat-steps-entity-cleanup-skip:commerce_product`. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext diff --git a/src/Steps/Drupal/FileTrait.php b/src/Steps/Drupal/FileTrait.php index d47065d28..4e3fa3ac9 100644 --- a/src/Steps/Drupal/FileTrait.php +++ b/src/Steps/Drupal/FileTrait.php @@ -267,7 +267,7 @@ public function fileAssertUnmanagedNotContains(string $uri, string $content): vo } // @codeCoverageIgnoreEnd if (str_contains($file_content, $content)) { - throw new ExpectationException(sprintf('File contents "%s" contains "%s", but should not.', $file_content, $content), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('File contents "%s" contains "%s", but it should not.', $file_content, $content), $this->getSession()->getDriver()); } } @@ -325,7 +325,7 @@ public function fileCreateEntity(string $path, EntityStubInterface $stub, ?strin } // @codeCoverageIgnoreEnd $destination = 'public://' . basename($path); - if (!empty($uri)) { + if ($uri !== NULL && $uri !== '') { $destination = $uri; $directory = dirname($destination); $dir = \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY + FileSystemInterface::MODIFY_PERMISSIONS); diff --git a/src/Steps/Drupal/LanguageTrait.php b/src/Steps/Drupal/LanguageTrait.php index 157d2d853..7f2fd6456 100644 --- a/src/Steps/Drupal/LanguageTrait.php +++ b/src/Steps/Drupal/LanguageTrait.php @@ -16,10 +16,11 @@ * - Add languages by their ISO code, skipping ones already installed. * * Languages created here are removed after the scenario along with every other - * entity the scenario created. A scenario that also installs the 'language' - * module leaves that removal to the module uninstall, with - * '@behat-steps-entity-cleanup-skip:language', because the two teardown hooks - * run in no guaranteed order. + * entity the scenario created. + * + * The 2 teardown hooks run in no guaranteed order. A scenario that also + * installs the 'language' module therefore leaves that removal to the module + * uninstall, with '@behat-steps-entity-cleanup-skip:language'. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ diff --git a/src/Steps/Drupal/MediaTrait.php b/src/Steps/Drupal/MediaTrait.php index 50a6a07ad..aa98452be 100644 --- a/src/Steps/Drupal/MediaTrait.php +++ b/src/Steps/Drupal/MediaTrait.php @@ -303,13 +303,13 @@ public function mediaCreateEntity(EntityStubInterface $stub): MediaInterface { $bundle = $stub->getBundle(); // @codeCoverageIgnoreStart - if (empty($bundle)) { + if ($bundle === NULL || $bundle === '') { throw new \RuntimeException('Cannot create media because it is missing the required bundle.'); } $bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); if (!array_key_exists($bundle, $bundles)) { - throw new \RuntimeException(sprintf("Cannot create media because provided bundle '%s' does not exist.", $bundle)); + throw new \RuntimeException(sprintf('Cannot create media because provided bundle "%s" does not exist.', $bundle)); } // @codeCoverageIgnoreEnd $this->mediaExpandEntityFieldsFixtures($stub); diff --git a/src/Steps/Drupal/MenuTrait.php b/src/Steps/Drupal/MenuTrait.php index 5041199d9..7aa37f0cb 100644 --- a/src/Steps/Drupal/MenuTrait.php +++ b/src/Steps/Drupal/MenuTrait.php @@ -15,7 +15,7 @@ use Drupal\system\MenuInterface; /** - * Manage Drupal menu systems and menu link rendering. + * Manage Drupal menus and menu links. * * - Create and remove menus by label. * - Create and remove menu links, including parent-child hierarchies. diff --git a/src/Steps/Drupal/ParagraphsTrait.php b/src/Steps/Drupal/ParagraphsTrait.php index b102740cc..d20f9accd 100644 --- a/src/Steps/Drupal/ParagraphsTrait.php +++ b/src/Steps/Drupal/ParagraphsTrait.php @@ -28,8 +28,8 @@ */ trait ParagraphsTrait { - use QueryTrait; use EntityLifecycleTrait; + use QueryTrait; /** * Create a paragraph of the given type with fields within an existing entity. diff --git a/src/Steps/Drupal/RedirectTrait.php b/src/Steps/Drupal/RedirectTrait.php index aec0e8197..094703e5d 100644 --- a/src/Steps/Drupal/RedirectTrait.php +++ b/src/Steps/Drupal/RedirectTrait.php @@ -18,7 +18,7 @@ /** * Manage Drupal redirect entities provided by the contrib `redirect` module. * - * - Create one or more redirects from a table of source/destination/status. + * - Create 1 or more redirects from a table of source/destination/status. * - Delete redirects by source path. * - Assert that redirects do or do not exist for given source paths. * - Created redirects are automatically removed at the end of the scenario. @@ -37,7 +37,7 @@ trait RedirectTrait { protected static array $redirectAllowedStatusCodes = [301, 302, 303, 307, 308]; /** - * Create one or more redirects. + * Create 1 or more redirects. * * The `status_code` column is optional and defaults to `301` when omitted * or left blank. Allowed values: 301, 302, 303, 307, 308. @@ -86,7 +86,7 @@ public function redirectCreate(TableNode $table): void { /** * Delete redirects by source path. * - * Each row is one source path. Rows that match no existing redirect are + * Each row is 1 source path. Rows that match no existing redirect are * silently skipped. * * @code @@ -121,7 +121,7 @@ public function redirectDelete(TableNode $table): void { } /** - * Assert that one or more redirects exist. + * Assert that 1 or more redirects exist. * * The `from` column is required. The `to` and `status_code` columns are * optional: when blank or omitted, only the source path is matched. @@ -182,9 +182,9 @@ public function redirectAssertExist(TableNode $table): void { } /** - * Assert that no redirect exists for one or more source paths. + * Assert that no redirect exists for 1 or more source paths. * - * Each row is one source path. + * Each row is 1 source path. * * @code * Then the following redirects should not exist: diff --git a/src/Steps/Drupal/SearchApiTrait.php b/src/Steps/Drupal/SearchApiTrait.php index ed6e00a66..48ff06626 100644 --- a/src/Steps/Drupal/SearchApiTrait.php +++ b/src/Steps/Drupal/SearchApiTrait.php @@ -12,10 +12,11 @@ use Drupal\node\Entity\Node; /** - * Assert Drupal Search API with index and query operations. + * Run Drupal Search API indexing and cron hooks. * - * - Add content to an index + * - Add content to an index. * - Run indexing for a specific number of items. + * - Run the Search API and Search API Solr cron hooks. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ diff --git a/src/Steps/Drupal/StateTrait.php b/src/Steps/Drupal/StateTrait.php index 097b5d605..3241eb4cd 100644 --- a/src/Steps/Drupal/StateTrait.php +++ b/src/Steps/Drupal/StateTrait.php @@ -168,10 +168,12 @@ public function stateAssertNotExists(string $name): void { /** * Read a state value, distinguishing stored NULL from a missing key. * - * Uses the underlying key/value store's `has()` so that a legitimately - * stored NULL is reported as existing. `\Drupal::state()->get()` cannot - * distinguish the two cases because it applies the `??` operator to - * the loaded value and returns the default for NULL. + * Existence is read through the backend's `stateExists()`, not from a NULL + * check on the value. A backend that can tell a stored NULL from an absent + * key then reports it as existing. + * + * `\Drupal::state()->get()` cannot tell the 2 cases apart: it applies the + * `??` operator to the loaded value and returns the default for NULL. * * @param string $name * The state key name. diff --git a/src/Steps/Drupal/TaxonomyTrait.php b/src/Steps/Drupal/TaxonomyTrait.php index ed9b8aaa9..5f7a4309b 100644 --- a/src/Steps/Drupal/TaxonomyTrait.php +++ b/src/Steps/Drupal/TaxonomyTrait.php @@ -21,7 +21,7 @@ * Manage Drupal taxonomy terms with vocabulary organization. * * - Create term vocabulary structures using field values. - * - Navigate to term pages + * - Navigate to term pages. * - Verify vocabulary configurations. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext @@ -184,14 +184,14 @@ public function taxonomyAssertVocabularyNotExists(string $vocabulary): void { } /** - * Assert that a taxonomy term exist by name. + * Assert that a taxonomy term exists by name. * * @code * Then the taxonomy term "Apple" from the vocabulary "Fruits" should exist * @endcode */ #[Then('the taxonomy term :term_name from the vocabulary :vocabulary should exist')] - public function taxonomyAssertTermExistsByName(string $term_name, string $vocabulary): void { + public function taxonomyAssertTermExistsWithName(string $term_name, string $vocabulary): void { $this->backendFor(CoreCapabilityInterface::class); $vocab = Vocabulary::load($vocabulary); @@ -217,7 +217,7 @@ public function taxonomyAssertTermExistsByName(string $term_name, string $vocabu * @endcode */ #[Then('the taxonomy term :term_name from the vocabulary :vocabulary should not exist')] - public function taxonomyAssertTermNotExistsByName(string $term_name, string $vocabulary): void { + public function taxonomyAssertTermNotExistsWithName(string $term_name, string $vocabulary): void { $this->backendFor(CoreCapabilityInterface::class); $vocab = Vocabulary::load($vocabulary); diff --git a/src/Steps/Drupal/TimeTrait.php b/src/Steps/Drupal/TimeTrait.php index 8b94c5419..d422d727d 100644 --- a/src/Steps/Drupal/TimeTrait.php +++ b/src/Steps/Drupal/TimeTrait.php @@ -36,7 +36,7 @@ trait TimeTrait { * Cleans up testing.time state after each scenario. */ #[AfterScenario] - public function timeCleanup(AfterScenarioScope $scope): void { + public function timeAfterScenario(AfterScenarioScope $scope): void { // A scenario that never set the time has nothing to clean up, and // resolving a backend would fail a suite that lists none reaching Drupal. if (!$this->timeWasSet || $this->skipTag(__TRAIT__, $scope)) { diff --git a/src/Steps/Drupal/UserTrait.php b/src/Steps/Drupal/UserTrait.php index 675052580..9bd8d3811 100644 --- a/src/Steps/Drupal/UserTrait.php +++ b/src/Steps/Drupal/UserTrait.php @@ -104,7 +104,7 @@ public function userCreateWithFields(TableNode $table): void { /** * Create users from a table of field values. * - * Each row becomes one user; each column is a base property or a field. A + * Each row becomes 1 user; each column is a base property or a field. A * `roles` column takes a comma-separated list, assigned after the account is * saved. A row without a `pass` column gets a random password. * @@ -160,7 +160,7 @@ public function userLogOutSession(): void { */ #[Given('the password for the user :name is :password')] public function userSetPassword(string $name, string $password): void { - if (empty($password)) { + if ($password === '') { throw new \RuntimeException('Password must not be empty.'); } @@ -508,7 +508,7 @@ public function userAssertNotHasRoles(string $name, string $roles): void { * @endcode */ #[Then('the user with the email :mail should exist')] - public function userAssertExistsByMail(string $mail): void { + public function userAssertExistsWithMail(string $mail): void { if (!$this->userExistsByMail($mail)) { throw new ExpectationException(sprintf('User with email "%s" is expected to exist, but they do not.', $mail), $this->getSession()->getDriver()); } @@ -524,7 +524,7 @@ public function userAssertExistsByMail(string $mail): void { * @endcode */ #[Then('the user with the email :mail should not exist')] - public function userAssertNotExistsByMail(string $mail): void { + public function userAssertNotExistsWithMail(string $mail): void { if ($this->userExistsByMail($mail)) { throw new ExpectationException(sprintf('User with email "%s" is expected to not exist, but they do.', $mail), $this->getSession()->getDriver()); } @@ -566,7 +566,7 @@ public function userAssertNotBlocked(string $name): void { * Create a user carrying the roles and extra fields, and log in as them. * * @param string $roles - * One role, or several as a comma-separated list. + * A single role, or several as a comma-separated list. * @param array $extra_fields * Additional values to set on the account. * @@ -592,8 +592,8 @@ public function userCreateAndLogIn(string $roles, array $extra_fields = []): voi * @param \DrevOps\BehatSteps\Backend\Entity\EntityStubInterface $stub * The saved user stub. * @param string $roles - * One role, or several as a comma-separated list. An empty string assigns - * nothing. + * A single role, or several as a comma-separated list. An empty string + * assigns nothing. */ public function userAssignRoles(UserCapabilityInterface $backend, EntityStubInterface $stub, string $roles): void { foreach (array_filter(array_map(trim(...), explode(',', $roles))) as $role) { @@ -705,7 +705,7 @@ public function userGetByName(string $name): UserInterface { $users = $this->userLoadMultiple(['name' => $name]); if (empty($users)) { - throw new \RuntimeException(sprintf('User with name "%s" does not exist.', $name)); + throw new \RuntimeException(sprintf('The user "%s" does not exist.', $name)); } return reset($users); diff --git a/src/Steps/Drupal/WatchdogTrait.php b/src/Steps/Drupal/WatchdogTrait.php index a9a13e5dd..cc0b4ed1d 100644 --- a/src/Steps/Drupal/WatchdogTrait.php +++ b/src/Steps/Drupal/WatchdogTrait.php @@ -81,7 +81,7 @@ trait WatchdogTrait { * Store the scenario identity, tracked message types and start time. */ #[BeforeScenario] - public function watchdogSetScenario(BeforeScenarioScope $scope): void { + public function watchdogBeforeScenario(BeforeScenarioScope $scope): void { if ($this->skipTag(__TRAIT__, $scope)) { return; } @@ -125,7 +125,7 @@ public function watchdogAfterStep(AfterStepScope $scope): void { } /** - * Check for errors that the last step could not have seen. + * Check for errors the last-step check could not have read. * * A scenario whose earlier step failed never ran its last step, so nothing * was checked at step scope. A scenario that passed may still log an error @@ -178,8 +178,8 @@ public function watchdogAssertErrorsNotExist(string $context): void { /** * Read the errors logged since the scenario started, and clear them. * - * Read entries are deleted so a later check in the same scenario sees only - * new ones. + * Read entries are deleted so a later check in the same scenario returns + * only new ones. * * @return array * Rendered entries at or above the severity threshold, keyed by their @@ -206,10 +206,10 @@ public function watchdogReadErrors(): array { define('WATCHDOG_WARNING', 4); } - // Remove entries less severe than a warning. - foreach ($entries as $k => $error) { + // Entries less severe than a warning are removed. + foreach ($entries as $key => $error) { if ($error->severity > WATCHDOG_WARNING) { - unset($entries[$k]); + unset($entries[$key]); continue; } $error->variables = unserialize($error->variables); diff --git a/src/Steps/Web/AccessibilityTrait.php b/src/Steps/Web/AccessibilityTrait.php index b45c852e4..2b05b2390 100644 --- a/src/Steps/Web/AccessibilityTrait.php +++ b/src/Steps/Web/AccessibilityTrait.php @@ -37,19 +37,21 @@ * - `@behat-steps-skip:AccessibilityTrait` Opt the scenario or feature out entirely. * * Tool-agnostic. Any engine that runs inside the existing Mink session can - * be plugged in by overriding `accessibilityRunEngine()` (perform the - * assessment, return raw results) and `accessibilityNormalizeResults()` - * (remap raw output into the canonical shape the rest of the trait expects). + * be plugged in by overriding `accessibilityRunEngine()` and + * `accessibilityNormalizeResults()`. The first performs the assessment and + * returns raw results; the second remaps raw output into the canonical shape + * the rest of the trait reads. * * Reporting. Each scenario writes its own HTML and JUnit report. After the * whole suite, a single cross-page `accessibility_report_.html` * (timestamp `YYYYMMDD_HHMMSS`) is written to the same directory, - * de-duplicating every assessed page and rolling violations up by rule. One - * file is written per run, so a run never overwrites a previous one. The + * de-duplicating every assessed page and rolling violations up by rule. + * + * 1 file is written per run, so a run never overwrites a previous one. The * aggregate accumulates in process-global state, so under parallel Behat each * process writes its own report. * - * Console output. A one-line per-page summary can be printed to the console + * Console output. A 1-line per-page summary can be printed to the console * as pages are assessed. Printing is off by default; set the * `BEHAT_ACCESSIBILITY_PRINT` environment variable to a non-empty value other * than `0`, or override `accessibilityGetPrintCli()`, to enable it. @@ -90,12 +92,13 @@ trait AccessibilityTrait { /** * Working directory captured before any test bootstrap can chdir(). * - * The default report directory anchors to this rather than a live - * `getcwd()` call. A Drupal bootstrap chdir()s to the docroot, so a live - * `getcwd()` would resolve the report directory outside the path-anchored - * location used by the rest of the run. Captured once at `@BeforeSuite`, - * before the first scenario, so it records the directory the run was - * launched from. + * The default report directory is resolved against this value rather than + * a live `getcwd()` call. A Drupal bootstrap chdir()s to the docroot, so a + * live `getcwd()` would resolve the report directory away from the rest of + * the run's paths. + * + * The value is captured once at `@BeforeSuite`, before the first scenario, + * so it records the directory the run was launched from. */ protected static ?string $accessibilityBaseDir = NULL; @@ -105,7 +108,7 @@ trait AccessibilityTrait { * Populated as each scenario finalizes and consumed once by the static * `@AfterSuite` renderer. URLs are stored already formatted for display. * - * @var array}>}> + * @var array, results: array}>}> */ protected static array $accessibilityAggregate = []; @@ -173,9 +176,6 @@ trait AccessibilityTrait { public static function accessibilityCaptureBaseDir(BeforeSuiteScope $scope): void { if (self::$accessibilityBaseDir === NULL) { $cwd = getcwd(); - // Leave the base unset when getcwd() fails so - // accessibilityGetReportDir() retries it later rather than locking in - // an empty, root-relative base. if ($cwd !== FALSE) { self::$accessibilityBaseDir = $cwd; } @@ -198,7 +198,7 @@ public static function accessibilityAggregateReset(BeforeSuiteScope $scope): voi * Initialize accessibility state for the scenario. */ #[BeforeScenario] - public function accessibilitySetupScenario(BeforeScenarioScope $scope): void { + public function accessibilityBeforeScenario(BeforeScenarioScope $scope): void { $this->accessibilityResults = []; $this->accessibilityAutoMode = FALSE; $this->accessibilityLastCheckedUrl = ''; @@ -229,7 +229,7 @@ public function accessibilitySetupScenario(BeforeScenarioScope $scope): void { * visited in the report before the gate is applied. */ #[AfterStep] - public function accessibilityAutoAssess(AfterStepScope $scope): void { + public function accessibilityAfterStep(AfterStepScope $scope): void { if ($this->accessibilitySkip) { return; } @@ -253,10 +253,10 @@ public function accessibilityAutoAssess(AfterStepScope $scope): void { $this->accessibilityAssess($this->accessibilityGetDefaultRules()); } - // A failed step has already failed the scenario, so gating on top of it - // would report a violation found on a page the step left incomplete. The - // gate is applied whether or not this step assessed a new page, because a - // last step that navigates nowhere still ends the scenario. + // A failed step has already failed the scenario, so gating it as well + // would report a violation from a page the step left incomplete. The gate + // runs whether or not this step assessed a new page, because a last step + // that does not navigate still ends the scenario. if (!$scope->getTestResult()->isPassed() || !$this->lastStepReached($scope)) { return; } @@ -268,16 +268,16 @@ public function accessibilityAutoAssess(AfterStepScope $scope): void { /** * Write the scenario reports, feed the suite aggregate, then gate if needed. * - * A step that fails on its own skips every step after it, including the one - * that would have applied the gate, so the gate is applied here instead. The - * scenario has already failed by then, so it cannot mask a passing scenario - * from the rerun cache. + * A failing step skips every step after it, including the one that applies + * the gate, so the gate is applied here instead. The scenario has already + * failed by then, so it cannot mask a passing scenario from the rerun + * cache. * * @throws \Behat\Mink\Exception\ExpectationException * If a violation at or above the threshold was collected. */ #[AfterScenario] - public function accessibilityFinalizeScenario(AfterScenarioScope $scope): void { + public function accessibilityAfterScenario(AfterScenarioScope $scope): void { if ($this->accessibilitySkip) { return; } @@ -307,7 +307,7 @@ public function accessibilityFinalizeScenario(AfterScenarioScope $scope): void { * Render the single cross-page report after the whole suite has run. */ #[AfterSuite] - public static function accessibilityAggregateRender(AfterSuiteScope $scope): void { + public static function accessibilityAfterSuite(AfterSuiteScope $scope): void { static::accessibilityWriteAggregateReport(); } @@ -368,15 +368,15 @@ protected function accessibilityEnforceGate(): void { $check_incomplete = $this->accessibilityEffectiveFailOnIncomplete(); $messages = []; - foreach ($this->accessibilityResults as $r) { - $display_url = $this->accessibilityFormatUrl((string) $r['url']); + foreach ($this->accessibilityResults as $result) { + $display_url = $this->accessibilityFormatUrl((string) $result['url']); - foreach ($this->accessibilityFilterViolations($r['result']['violations'] ?? [], $threshold) as $v) { - $messages[] = sprintf(' violation [%s] %s on %s', $v['impact'] ?? 'unknown', $v['id'] ?? '', $display_url); + foreach ($this->accessibilityFilterViolations($result['result']['violations'] ?? [], $threshold) as $violation) { + $messages[] = sprintf(' violation [%s] %s on %s', $violation['impact'] ?? 'unknown', $violation['id'] ?? '', $display_url); } if ($check_incomplete) { - foreach ($r['result']['incomplete'] ?? [] as $i) { - $messages[] = sprintf(' incomplete [%s] %s on %s', $i['impact'] ?? 'unknown', $i['id'] ?? '', $display_url); + foreach ($result['result']['incomplete'] ?? [] as $issue) { + $messages[] = sprintf(' incomplete [%s] %s on %s', $issue['impact'] ?? 'unknown', $issue['id'] ?? '', $display_url); } } } @@ -391,11 +391,11 @@ protected function accessibilityEnforceGate(): void { * Return the JavaScript source to inject into the page. * * Default: fetched once per process from accessibilityGetCdnUrl(). Override - * to ship the engine script from a vendored package or asset path. + * to return the engine script from a vendored package or asset path. * * A read is bounded by accessibilityGetFetchTimeout() and retried up to - * accessibilityGetFetchAttempts() times, so a stalled or throttled source - * costs a bounded wait per attempt instead of blocking until PHP's own + * accessibilityGetFetchAttempts() times. A stalled or throttled source + * blocks for at most the timeout per attempt, not until PHP's own * default_socket_timeout expires. */ public function accessibilityGetJs(): string { @@ -435,8 +435,10 @@ public function accessibilityGetJs(): string { * Default: a single read bounded by the given timeout, returning FALSE * when the read fails. An HTTP or HTTPS location is fetched through the * bare client, which carries no scenario state; any other location is read - * as a local file. Override to fetch through a different HTTP client; - * accessibilityGetJs() supplies the retries around it. + * as a local file. + * + * Override to fetch through a different HTTP client; accessibilityGetJs() + * supplies the retries around it. * * @param string $url * Location the engine source is read from. @@ -498,8 +500,9 @@ public function accessibilityGetCdnUrl(): string { * * A relative `report_dir` is resolved against the directory the run was * launched from. That base is captured at `@BeforeSuite`, so it is stable - * even after a Drupal bootstrap chdir()s to the docroot. When the suite hook - * has not run, the live working directory is used. + * even after a Drupal bootstrap chdir()s to the docroot. + * + * When the suite hook has not run, the live working directory is used. */ public function accessibilityGetReportDir(): string { $directory = $this->getOptionString('accessibility', 'report_dir'); @@ -519,7 +522,9 @@ public function accessibilityGetReportDir(): string { * The trait recognises this exact tag plus value variants * (`:critical`, `:serious`, `:moderate`, `:minor`, * `:warning`, `:strict`, `:any`) for per-scenario gate - * configuration. Default: `accessibility`. Override to shorten. + * configuration. + * + * Default: `accessibility`. Override to shorten. */ public function accessibilityGetAutoTag(): string { return $this->getOptionString('accessibility', 'auto_tag'); @@ -555,7 +560,7 @@ public function accessibilityGetFailOnIncomplete(): bool { } /** - * Return TRUE to print a one-line per-page summary to the console. + * Return TRUE to print a 1-line per-page summary to the console. * * Default: enabled only when the `BEHAT_ACCESSIBILITY_PRINT` environment * variable is set to a non-empty value other than `0`. Override to @@ -570,7 +575,7 @@ public function accessibilityGetPrintCli(): bool { /** * Return the canonical impact levels in descending severity order. * - * Default: the four `ACCESSIBILITY_IMPACT_*` constants on this trait. + * Default: the 4 `ACCESSIBILITY_IMPACT_*` constants on this trait. * Engines with a different severity vocabulary map to these constants * inside `accessibilityNormalizeResults()`. * @@ -601,9 +606,11 @@ protected static function accessibilityGetDefaultImpacts(): array { * * Default: injects `accessibilityGetJs()`, runs the engine with the * given rule identifier, returns the engine's native output. Override - * to call a different engine. The return value is fed to - * `accessibilityNormalizeResults()` before any other trait logic touches - * it, so the raw shape does not have to match the canonical shape. + * to call a different engine. + * + * The return value is passed to `accessibilityNormalizeResults()` before + * any other trait method reads it, so the raw shape does not have to match + * the canonical shape. * * @param string $rules * Engine-specific rule identifier. @@ -623,10 +630,9 @@ public function accessibilityRunEngine(string $rules): array { )); $session->wait(30000, 'window.__accessibilityResults !== null'); - // Serialize to a JSON string in the browser rather than returning the raw - // object: the result graph is large and nested, and some browser drivers - // (e.g. chrome-mink) cannot walk every property when marshalling a live - // object. + // Serialize to a JSON string in the browser. The result graph is large + // and nested, and some browser drivers (e.g. chrome-mink) cannot walk + // every property when marshalling a live object. $results = json_decode((string) $session->evaluateScript('return JSON.stringify(window.__accessibilityResults);'), TRUE); if (!is_array($results)) { @@ -647,14 +653,13 @@ public function accessibilityRunEngine(string $rules): array { * `['violations' => [...], 'incomplete' => [...], 'passes' => [...]]` * * Default: maps each finding into the canonical fields explicitly. The - * default engine's native shape happens to share field names with the - * canonical shape, so this default mostly copies values straight across. - * Each field is still named at the call site, so the method also serves - * as a template for overrides. + * default engine's native shape shares field names with the canonical + * shape, so this default mostly copies values straight across. * - * Override when wiring a different engine to map its native output (e.g. - * pa11y's `issues[]`, Lighthouse's `audits`) into the canonical - * structure. + * Each field is still named individually, so the method also serves as a + * template for overrides. Override when wiring a different engine to map + * its native output (e.g. pa11y's `issues[]`, Lighthouse's `audits`) into + * the canonical structure. * * @param array $raw * Raw result from `accessibilityRunEngine()`. @@ -783,11 +788,11 @@ protected function accessibilityFilterViolations(array $violations, string $thre } $filtered = []; - foreach ($violations as $v) { - $impact = strtolower((string) ($v['impact'] ?? '')); + foreach ($violations as $violation) { + $impact = strtolower((string) ($violation['impact'] ?? '')); $pos = array_search($impact, $impacts, TRUE); if ($pos !== FALSE && $pos <= $threshold_pos) { - $filtered[] = $v; + $filtered[] = $violation; } } @@ -845,10 +850,10 @@ protected function accessibilityFormatGateMessage(string $url, string $rules, st sprintf('Accessibility gate failed on %s (rules: %s, threshold: %s, fail_on_incomplete: %s):', $this->accessibilityFormatUrl($url), $rules, $threshold, $check_incomplete ? 'yes' : 'no'), ]; - foreach ($violations as $v) { - $lines[] = sprintf(' violation [%s] %s - %s', $v['impact'] ?? 'unknown', $v['id'], $v['help']); - $lines[] = sprintf(' %s', $v['helpUrl']); - foreach ($v['nodes'] ?? [] as $node) { + foreach ($violations as $violation) { + $lines[] = sprintf(' violation [%s] %s - %s', $violation['impact'] ?? 'unknown', $violation['id'], $violation['help']); + $lines[] = sprintf(' %s', $violation['helpUrl']); + foreach ($violation['nodes'] ?? [] as $node) { $lines[] = sprintf(' -> %s', static::accessibilityStringifyTarget($node['target'] ?? [])); $html = trim((string) ($node['html'] ?? '')); if ($html !== '') { @@ -857,10 +862,10 @@ protected function accessibilityFormatGateMessage(string $url, string $rules, st } } - foreach ($incomplete as $i) { - $lines[] = sprintf(' incomplete [%s] %s - %s', $i['impact'] ?? 'unknown', $i['id'], $i['help']); - $lines[] = sprintf(' %s', $i['helpUrl']); - foreach ($i['nodes'] ?? [] as $node) { + foreach ($incomplete as $issue) { + $lines[] = sprintf(' incomplete [%s] %s - %s', $issue['impact'] ?? 'unknown', $issue['id'], $issue['help']); + $lines[] = sprintf(' %s', $issue['helpUrl']); + foreach ($issue['nodes'] ?? [] as $node) { $lines[] = sprintf(' -> %s', static::accessibilityStringifyTarget($node['target'] ?? [])); } } @@ -883,14 +888,14 @@ protected static function accessibilityStringifyTarget(array $target): string { * * Default: strip the configured Mink `base_url` prefix so reports show the * page path (`/contact`) rather than the internal host and port - * (`http://nginx:8080/contact`). The absolute form is noise and makes - * reports non-portable. The base URL itself maps to `/` and the query - * string is kept. - * - * Only the known `base_url` is stripped: a genuinely cross-origin URL - * captured during assessment stays absolute, so it remains - * distinguishable. Override to keep the absolute URL or to format it - * differently. + * (`http://nginx:8080/contact`). The absolute form adds no information and + * makes reports non-portable. + * + * The base URL itself maps to `/` and the query string is kept. Only the + * known `base_url` is stripped: a cross-origin URL captured during + * assessment stays absolute, so it remains distinguishable. + * + * Override to keep the absolute URL or to format it differently. */ protected function accessibilityFormatUrl(string $url): string { $base = rtrim((string) $this->getMinkParameter('base_url'), '/'); @@ -925,8 +930,8 @@ protected static function accessibilityBlankUrls(): array { /** * Render the scenario-level HTML report from collected results. * - * Composes the page wrapper around the per-URL section markup. The two - * pieces are split so consumers can rebrand the page without touching + * Composes the page wrapper around the per-URL section markup. The 2 + * pieces are split so consumers can rebrand the page without changing * the section logic. */ protected function accessibilityRenderHtml(): string { @@ -938,8 +943,8 @@ protected function accessibilityRenderHtml(): string { * * Default: a self-contained HTML document with the trait's built-in * styles. Override to brand the report (custom doctype, header/footer, - * external stylesheet, project logo, etc.) without having to rebuild - * the section markup - the caller already supplies it as `$sections`. + * external stylesheet, project logo, etc.) without rebuilding the section + * markup, which `$sections` already holds. * * @param string $sections * Pre-rendered per-URL section markup from @@ -988,7 +993,7 @@ protected function accessibilityRenderHtmlPage(string $sections): string { } /** - * Render the per-URL section markup (one `
` per visited URL). + * Render the per-URL section markup (1 `
` per visited URL). * * Returns only the inner content that the page wrapper embeds. Override * to change how each section renders (rare); for branding the @@ -997,12 +1002,12 @@ protected function accessibilityRenderHtmlPage(string $sections): string { protected function accessibilityRenderHtmlSections(): string { $body_sections = []; - foreach ($this->accessibilityResults as $r) { - $url = htmlspecialchars($this->accessibilityFormatUrl((string) $r['url']), ENT_QUOTES); - $rules = htmlspecialchars((string) $r['rules'], ENT_QUOTES); - $violations = $r['result']['violations'] ?? []; - $incomplete = $r['result']['incomplete'] ?? []; - $passes_count = count($r['result']['passes'] ?? []); + foreach ($this->accessibilityResults as $result) { + $url = htmlspecialchars($this->accessibilityFormatUrl((string) $result['url']), ENT_QUOTES); + $rules = htmlspecialchars((string) $result['rules'], ENT_QUOTES); + $violations = $result['result']['violations'] ?? []; + $incomplete = $result['result']['incomplete'] ?? []; + $passes_count = count($result['result']['passes'] ?? []); $section = sprintf('

%s

Rules: %s · %d violations · %d incomplete · %d passes

', $url, $rules, count($violations), count($incomplete), $passes_count); @@ -1060,14 +1065,17 @@ protected function accessibilityRenderIssueList(string $heading, string $css_cla * * Violations are gated by the scenario's effective threshold, exactly as * the pass/fail gate is: only violations meeting the threshold are - * serialised as `` cases. An advisory run (threshold `never`) - * therefore writes a report with zero failures instead of one that fails + * serialized as `` cases. An advisory run (threshold `never`) + * therefore writes a report with 0 failures instead of one that fails * a JUnit-consuming CI check. * * Violations below the threshold are recorded as passing cases carrying * the finding in ``, so they stay visible without failing the - * report. The `tests` and `failures` counts reflect the actual emitted - * `` elements, one per affected node. + * report. + * + * A violation emits 1 `` per affected node and a passed rule + * emits 1 `` with no node. `tests` counts every case and + * `failures` counts the cases carrying a ``. */ protected function accessibilityRenderJunit(): string { $threshold = $this->accessibilityEffectiveThreshold(); @@ -1075,23 +1083,23 @@ protected function accessibilityRenderJunit(): string { $total_tests = 0; $total_failures = 0; - foreach ($this->accessibilityResults as $r) { - $url = $this->accessibilityFormatUrl((string) $r['url']); - $violations = $r['result']['violations'] ?? []; - $passes = $r['result']['passes'] ?? []; + foreach ($this->accessibilityResults as $result) { + $url = $this->accessibilityFormatUrl((string) $result['url']); + $violations = $result['result']['violations'] ?? []; + $passes = $result['result']['passes'] ?? []; $cases_xml = ''; $tests = 0; $failures = 0; - foreach ($violations as $v) { - $failing = $this->accessibilityFilterViolations([$v], $threshold) !== []; - $rule_id = (string) ($v['id'] ?? 'unknown'); - $impact = (string) ($v['impact'] ?? 'unknown'); - $help = (string) ($v['help'] ?? ''); - $help_url = (string) ($v['helpUrl'] ?? ''); + foreach ($violations as $violation) { + $failing = $this->accessibilityFilterViolations([$violation], $threshold) !== []; + $rule_id = (string) ($violation['id'] ?? 'unknown'); + $impact = (string) ($violation['impact'] ?? 'unknown'); + $help = (string) ($violation['help'] ?? ''); + $help_url = (string) ($violation['helpUrl'] ?? ''); - foreach ($v['nodes'] ?? [] as $node) { + foreach ($violation['nodes'] ?? [] as $node) { $target = static::accessibilityStringifyTarget($node['target'] ?? []); $html = trim((string) ($node['html'] ?? '')); $details = sprintf("URL: %s\nRule: %s\nTarget: %s\nHTML: %s\nDocs: %s", $url, $rule_id, $target, $html, $help_url); @@ -1109,8 +1117,8 @@ protected function accessibilityRenderJunit(): string { } } - foreach ($passes as $p) { - $rule_id = (string) ($p['id'] ?? 'unknown'); + foreach ($passes as $pass) { + $rule_id = (string) ($pass['id'] ?? 'unknown'); $tests++; $cases_xml .= sprintf('', htmlspecialchars($rule_id, ENT_XML1 | ENT_QUOTES), htmlspecialchars($rule_id, ENT_XML1 | ENT_QUOTES)); } @@ -1133,9 +1141,9 @@ protected function accessibilityRenderJunit(): string { /** * Record the scenario's formatted results for the suite-level aggregate. * - * URLs are formatted here, in the instance phase, so the static renderer can - * reuse the one `accessibilityFormatUrl()` helper (and any consumer override - * of it) without an instance to call it on. + * The static renderer has no instance to call `accessibilityFormatUrl()` + * or a consumer override of it on, so URLs are formatted in the instance + * phase. * * @param string $dir * The resolved per-scenario report directory, captured for the static @@ -1157,7 +1165,7 @@ protected function accessibilityAggregateCapture(string $dir): void { 'feature' => $this->accessibilityFeatureName, 'scenario' => $this->accessibilityScenarioName, 'threshold' => $this->accessibilityEffectiveThreshold(), - 'failOnIncomplete' => $this->accessibilityEffectiveFailOnIncomplete(), + 'fail_on_incomplete' => $this->accessibilityEffectiveFailOnIncomplete(), // The suite renderer is static and cannot call an override, so the // impact list the scenario was gated under is stored with its results. 'impacts' => $this->accessibilityGetImpacts(), @@ -1166,12 +1174,14 @@ protected function accessibilityAggregateCapture(string $dir): void { } /** - * Write the aggregate report when at least one scenario produced results. + * Write the aggregate report when at least 1 scenario produced results. * - * One timestamped file is written per suite run, so a run never overwrites a - * previous one. The same timestamp drives the filename and the in-page - * "generated" line; it is resolved here so the render methods stay - * deterministic for tests. + * 1 timestamped file is written per suite run, so a run never overwrites a + * previous one. + * + * The same timestamp is used for the filename and the in-page "generated" + * line. It is resolved here so the render methods stay deterministic for + * tests. */ protected static function accessibilityWriteAggregateReport(): void { if (self::$accessibilityAggregate === []) { @@ -1208,7 +1218,7 @@ protected static function accessibilityAggregateFilename(int $time): string { * The accumulated per-scenario results. * * @return array> - * One entry per unique URL, in first-seen order, each holding its + * 1 entry per unique URL, in first-seen order, each holding its * violations, incomplete and passes counts, and visiting scenarios. */ protected static function accessibilityAggregatePages(array $aggregate): array { @@ -1317,12 +1327,11 @@ protected static function accessibilityAggregateRollup(array $pages, array $impa } /** - * Assemble every value the renderer needs into one data array. + * Assemble every value the renderer reads into 1 data array. * - * All calculation happens here and in the methods it calls - - * de-duplication, severity tallies, sorting, counting, and target - * flattening - so the single renderer only has to turn ready values into - * markup. + * De-duplication, severity tallies, sorting, counting, and target + * flattening all happen here and in the methods it calls. The single + * renderer only turns ready values into markup. * * @param array> $aggregate * The accumulated per-scenario results. @@ -1392,7 +1401,7 @@ protected static function accessibilityAggregateData(array $aggregate, string $g 'feature' => (string) ($entry['feature'] ?? ''), 'scenario' => (string) ($entry['scenario'] ?? ''), 'threshold' => (string) ($entry['threshold'] ?? ''), - 'failOnIncomplete' => (bool) ($entry['failOnIncomplete'] ?? FALSE), + 'fail_on_incomplete' => (bool) ($entry['fail_on_incomplete'] ?? FALSE), 'pages' => $detail, ]; } @@ -1444,10 +1453,12 @@ protected static function accessibilityAggregateFindings(array $issues): array { /** * Render the entire aggregate report from prepared data. * - * This is the single rendering entry point. Every value it needs is already - * computed in `$data` by accessibilityAggregateData(), so a consumer can - * override this one method to completely restyle the report - markup, CSS, - * and layout - without touching any of the aggregation logic. + * This is the single rendering entry point. Every value it reads is already + * computed in `$data` by accessibilityAggregateData(). + * + * A consumer can therefore override this method alone to completely + * restyle the report - markup, CSS, and layout - without changing any of + * the aggregation logic. * * @param array $data * Render-ready data from accessibilityAggregateData(). @@ -1525,7 +1536,7 @@ protected static function accessibilityRenderAggregate(array $data): string { foreach ($scenario['pages'] ?? [] as $page) { $sections[] = sprintf('

%s

Rules: %s · %d violations · %d incomplete · %d passes

%s%s
', htmlspecialchars((string) ($page['url'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($page['rules'] ?? ''), ENT_QUOTES), (int) ($page['violation_count'] ?? 0), (int) ($page['incomplete_count'] ?? 0), (int) ($page['passes_count'] ?? 0), $issue_list('Violations', 'violation', $page['violations'] ?? []), $issue_list('Incomplete (needs human review)', 'incomplete', $page['incomplete'] ?? [])); } - $detail[] = sprintf('

%s %s

threshold: %s · fail on incomplete: %s

%s
', htmlspecialchars((string) ($scenario['scenario'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['feature'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['threshold'] ?? ''), ENT_QUOTES), ($scenario['failOnIncomplete'] ?? FALSE) ? 'yes' : 'no', implode('', $sections)); + $detail[] = sprintf('

%s %s

threshold: %s · fail on incomplete: %s

%s
', htmlspecialchars((string) ($scenario['scenario'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['feature'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['threshold'] ?? ''), ENT_QUOTES), ($scenario['fail_on_incomplete'] ?? FALSE) ? 'yes' : 'no', implode('', $sections)); } $scenarios_section = '

Per-scenario detail

Every page each scenario assessed, in order, with its full findings embedded.

' . implode('', $detail) . '
'; diff --git a/src/Steps/Web/CommandTrait.php b/src/Steps/Web/CommandTrait.php index 696ec9a70..7d845a648 100644 --- a/src/Steps/Web/CommandTrait.php +++ b/src/Steps/Web/CommandTrait.php @@ -23,7 +23,8 @@ * * Commands run through the system shell with the privileges of the process * that runs the tests. The command string is passed to the shell verbatim and - * is subject to shell expansion, so never interpolate untrusted input into it. + * is subject to shell expansion, so untrusted input must never be + * interpolated into it. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ @@ -374,7 +375,7 @@ protected function commandParseNumeric(string $value, string $label): float { * When the value is not an integer. */ protected function commandParseInteger(string $value, string $label): int { - if (!preg_match('/^-?\d+$/', $value)) { + if (preg_match('/^-?\d+$/', $value) !== 1) { throw new \RuntimeException(sprintf('The %s must be an integer, but got "%s".', $label, $value)); } diff --git a/src/Steps/Web/CookieTrait.php b/src/Steps/Web/CookieTrait.php index e20cd8d84..0f236c53f 100644 --- a/src/Steps/Web/CookieTrait.php +++ b/src/Steps/Web/CookieTrait.php @@ -12,8 +12,8 @@ * Verify and inspect browser cookies. * * - Assert cookie existence and values with exact or partial matching. - * - Support both WebDriver and BrowserKit browser drivers for test - * compatibility. + * - Read cookies through whichever browser driver provides the cookie + * capability. * * @phpstan-require-extends \DrevOps\BehatSteps\Behat\Context\WebRawContext */ @@ -271,8 +271,8 @@ public function cookieFindByName(string $name, bool $is_partial = FALSE): ?array public function cookieGetAll(): array { $cookies = $this->browserDriverFor(CookieCapabilityInterface::class)->cookieGetAll(); - // The capability reports wire-form values; an assertion compares against - // the value a step was written with. + // The capability returns URL-encoded values; a step compares against the + // literal written in the feature. foreach ($cookies as &$cookie) { $cookie['value'] = rawurldecode($cookie['value']); } diff --git a/src/Steps/Web/DateTrait.php b/src/Steps/Web/DateTrait.php index 5a161aab0..5e2ea21fb 100644 --- a/src/Steps/Web/DateTrait.php +++ b/src/Steps/Web/DateTrait.php @@ -25,12 +25,14 @@ * * Examples: * - `[relative:-1 day]` converted to `1893456000` - * - `[relative:-1 day#Y-m-d]` converted to `2017-11-5` + * - `[relative:-1 day#Y-m-d]` converted to `2017-11-05` * - * `dateRelativeProcessValue()` is public API. It and its helpers are static so - * a token resolves without a context instance. Late static binding routes the - * resolution through a `dateGetNow()` override in the composing context, - * which is the supported seam for pinning the clock. + * `dateRelativeProcessValue()` is public API. It and its helpers are static, + * so a token resolves without a context instance. + * + * Late static binding routes the resolution through a `dateGetNow()` override + * in the composing context. That override is the supported way to hold the + * current time constant. * * Skip processing with tag: `@behat-steps-skip:DateTrait`. * @@ -104,7 +106,7 @@ public function dateRelativeTransformTable(TableNode $table): TableNode { * * Examples: * [relative:-1 day] would be converted to 1893456000 - * [relative:-1 day#Y-m-d] would be converted to 2017-11-5 + * [relative:-1 day#Y-m-d] would be converted to 2017-11-05 * * @code * Given the following "article" content: @@ -112,17 +114,17 @@ public function dateRelativeTransformTable(TableNode $table): TableNode { * | test article | [relative:-1 day] | * @endcode * - * @note A formatted return value can land on a different day than the - * scenario expects when the offset crosses midnight, because an absent - * 'now' resolves to the current minute rather than a fixed time of day. + * @note An absent `$now` resolves to the current minute, not a fixed time of + * day. A formatted return value whose offset crosses midnight can then fall + * on a different day than the scenario expects. */ public static function dateRelativeProcessValue(string $value, ?int $now = NULL): string { if (!static::dateRelativeStringHasToken($value)) { return $value; } - // An absent `now` truncates to the current minute, so every assertion in - // a long-running scenario resolves against the same base timestamp. + // An absent `now` truncates to the current minute, so tokens resolved + // within the same minute share a base timestamp. $now = $now ?: strtotime(date('Y-m-d H:i:00', static::dateGetNow())); $now = $now ?: NULL; diff --git a/src/Steps/Web/DiagnosticsTrait.php b/src/Steps/Web/DiagnosticsTrait.php index 08d380894..521f405d2 100644 --- a/src/Steps/Web/DiagnosticsTrait.php +++ b/src/Steps/Web/DiagnosticsTrait.php @@ -14,9 +14,9 @@ /** * Append on-failure diagnostics to the failure message of any failed step. * - * When a step fails, the exception message alone is often not enough to - * diagnose a red CI run. This trait hooks every step and, only when the step - * failed, appends a compact diagnostics block to the failure message: + * The exception message of a failed step is often not enough to diagnose a + * CI failure. This trait hooks every step and, only when the step failed, + * appends a compact diagnostics block to the failure message: * * - `URL` - the current page URL. * - `HTTP status` - the last response status code. @@ -210,7 +210,7 @@ public function diagnosticsFindBrowserDriverName(): ?string { /** * Return collected JavaScript console error messages. * - * Two sources are merged and de-duplicated: the `JavascriptTrait` registry + * 2 sources are merged and de-duplicated: the `JavascriptTrait` registry * when the context also uses it, and the live browser buffer its collector * populates. The registry is detected at runtime, so there is no hard * dependency on that trait. Both are best-effort and yield nothing under a diff --git a/src/Steps/Web/DropzoneTrait.php b/src/Steps/Web/DropzoneTrait.php index f95bcb6fa..a55da1818 100644 --- a/src/Steps/Web/DropzoneTrait.php +++ b/src/Steps/Web/DropzoneTrait.php @@ -12,15 +12,16 @@ /** * Simulate a real multi-file drag-and-drop gesture onto a Dropzone target. * - * - Drop one or more files on a CSS-selected target in a single native event. + * - Drop 1 or more files on a CSS-selected target in a single native event. * - Fixture paths resolve against the Mink `files_path` parameter. * - Works on any element that handles native `drop` events (Dropzone.js, * custom drop targets, framework widgets). * * Mink's `attachFile` writes each file to a hidden `` * sequentially, so file A finishes uploading before file B starts. Real users - * release multiple files together, which fires a single `drop` event whose - * `dataTransfer.files` contains all of them and triggers concurrent uploads. + * release multiple files together, so a single `drop` event carries all of + * them in `dataTransfer.files` and triggers concurrent uploads. + * * Race conditions in dedup maps, status indicators, error handlers and * server-side queues reproduce only under the multi-file path. * @@ -45,9 +46,9 @@ public function dropzoneDropFile(string $path, string $selector): void { } /** - * Drop one or more files on the target element in a single native event. + * Drop 1 or more files on the target element in a single native event. * - * Provide one fixture path per row. + * Provide 1 fixture path per row. * * @code * When I drop the following files on the dropzone ".dropzone": diff --git a/src/Steps/Web/ElementTrait.php b/src/Steps/Web/ElementTrait.php index 1782b2f03..e7fd08086 100644 --- a/src/Steps/Web/ElementTrait.php +++ b/src/Steps/Web/ElementTrait.php @@ -84,7 +84,7 @@ public function elementClick(string $selector): void { * @javascript */ #[When('I click on the element :selector with the index :index')] - public function elementClickByIndex(string $selector, int $index): void { + public function elementClickWithIndex(string $selector, int $index): void { $elements = $this->getSession()->getPage()->findAll('css', $selector); $this->elementGetNth($elements, $index, sprintf('element matching "%s"', $selector))->click(); } @@ -97,7 +97,7 @@ public function elementClickByIndex(string $selector, int $index): void { * @endcode */ #[When('I follow the link :link with the index :index')] - public function elementFollowLinkByIndex(string $link, int $index): void { + public function elementFollowLinkWithIndex(string $link, int $index): void { $elements = $this->getSession()->getPage()->findAll('named', ['link', $link]); $this->elementGetNth($elements, $index, sprintf('link "%s"', $link))->click(); } @@ -110,13 +110,13 @@ public function elementFollowLinkByIndex(string $link, int $index): void { * @endcode */ #[When('I press the button :button with the index :index')] - public function elementPressButtonByIndex(string $button, int $index): void { + public function elementPressButtonWithIndex(string $button, int $index): void { $elements = $this->getSession()->getPage()->findAll('named', ['button', $button]); $this->elementGetNth($elements, $index, sprintf('button "%s"', $button))->press(); } /** - * When I trigger the JS event :event on the element :selector. + * Trigger a JS event on the element defined by the selector. * * @code * When I trigger the JS event "click" on the element "#submit-button" @@ -129,11 +129,11 @@ public function elementTriggerEvent(string $event, string $selector): void { } /** - * Scroll to an element with ID. + * Scroll to the element matching a CSS selector. * - * By default, scrolls the element to the center of the viewport. Override - * the elementGetScrollIntoViewCenter() method to return FALSE to use the - * behavior that aligns the element to the top of the viewport. + * The element is scrolled to the center of the viewport by default. An + * elementGetScrollIntoViewCenter() override returning FALSE aligns it to + * the top of the viewport instead. * * @code * When I scroll to the element "#footer" @@ -342,7 +342,7 @@ public function elementAssertExistsWithAttributeContainingValue(string $selector } /** - * Assert an element with selector and attribute with a value exists. + * Assert an element with selector and attribute with a value does not exist. * * @code * Then the element "#main-content" with the attribute "class" and the value "hidden" should not exist @@ -369,9 +369,10 @@ public function elementAssertNotExistsWithAttributeContainingValue(string $selec * Assert an element has a computed CSS property with a value. * * The value is compared against the value computed by the browser, not - * against the value written in the stylesheet: `color: red` computes to - * `rgb(255, 0, 0)` and `margin: 1em` computes to a pixel length. The - * property name is accepted in either `background-color` or + * against the value written in the stylesheet. `color: red` computes to + * `rgb(255, 0, 0)` and `margin: 1em` computes to a pixel length. + * + * The property name is accepted in either `background-color` or * `backgroundColor` form; CSS custom properties are used verbatim. The * assertion applies to the first element matching the selector. * @@ -434,16 +435,16 @@ public function elementAssertCssPropertyNotContains(string $selector, string $pr /** * Assert that one element stacks above another. * - * Compares the effective paint order rather than the `z-index` property: - * a `z-index` read from an element is only meaningful within its own - * stacking context, so a child of a stacking-context-forming ancestor can - * carry a high `z-index` and still paint below an element with a lower one. + * Compares the effective paint order rather than the `z-index` property, + * because a `z-index` is only meaningful within its own stacking context. + * A child of a stacking-context-forming ancestor can carry a high `z-index` + * and still paint below an element with a lower one. * * The comparison walks the stacking context chain of both elements, finds - * the context they share, and compares the two participants that branch off - * it, using document order to break a tie. Painting order within a single - * stacking context (floats, inline content and positioned descendants) is - * not modelled. + * the context they share, and compares the 2 participants that branch off + * it. Document order breaks a tie; painting order within a single stacking + * context (floats, inline content and positioned descendants) is not + * modelled. * * @code * Then the element "#modal" should stack above the element "#page-header" @@ -471,7 +472,7 @@ public function elementAssertStacksBelow(string $selector1, string $selector2): } /** - * Assert the element :selector should be at the top of the viewport. + * Assert that the element is at the top of the viewport. * * @code * Then the element "#header" should be at the top of the viewport @@ -486,7 +487,7 @@ public function elementAssertElementAtTopOfViewport(string $selector): void { } /** - * Assert the element :selector should be centered in the viewport. + * Assert that the element is centered in the viewport. * * Checks that the vertical center of the element is within the middle third * of the viewport. @@ -506,9 +507,10 @@ public function elementAssertElementCenteredInViewport(string $selector): void { /** * Assert that an element is pinned to the top of the viewport. * - * The element's top edge has to sit within 2 pixels of the viewport top, - * which absorbs the sub-pixel offsets that normal rendering produces. Use - * the step with an explicit tolerance for layouts that need a larger one. + * The element's top edge must be within 2 pixels of the viewport top; the + * tolerance covers the sub-pixel offsets that normal rendering produces. + * Use the step with an explicit tolerance for layouts that require a + * larger one. * * This asserts where the element currently renders, so scroll the page * first to tell a pinned element apart from one that starts at the top of @@ -660,7 +662,7 @@ public function elementAssertNotVisible(string $selector): void { foreach ($elements as $element) { if ($element->isVisible()) { - throw new ExpectationException(sprintf('Element defined by "%s" selector is visible on the page, but should not be.', $selector), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('Element defined by "%s" selector is visible on the page, but it should not be.', $selector), $this->getSession()->getDriver()); } } } @@ -706,7 +708,7 @@ public function elementAssertVisuallyVisibleWithOffset(string $selector, int $of #[Then('the element :selector should not be displayed within a viewport with a top offset of :offset pixels')] public function elementAssertNotVisuallyVisibleWithOffset(string $selector, int $offset): void { if ($this->elementIsVisuallyVisible($selector, $offset)) { - throw new ExpectationException(sprintf('Element(s) defined by "%s" selector is displayed within a viewport with a top offset of %d pixels, but should not be.', $selector, $offset), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('Element(s) defined by "%s" selector is displayed within a viewport with a top offset of %d pixels, but it should not be.', $selector, $offset), $this->getSession()->getDriver()); } } @@ -725,7 +727,7 @@ public function elementAssertNotVisuallyVisibleWithOffset(string $selector, int #[Then('the element :selector should not be displayed within a viewport')] public function elementAssertNotVisuallyVisible(string $selector, int $offset = 0): void { if ($this->elementIsVisuallyVisible($selector, $offset)) { - throw new ExpectationException(sprintf('Element(s) defined by "%s" selector is displayed within a viewport, but should not be.', $selector), $this->getSession()->getDriver()); + throw new ExpectationException(sprintf('Element(s) defined by "%s" selector is displayed within a viewport, but it should not be.', $selector), $this->getSession()->getDriver()); } } @@ -826,7 +828,7 @@ protected function elementAssertAttributeWithValue(string $selector, string $att $attribute_value_found = FALSE; foreach ($elements as $element) { $attribute_value = (string) $element->getAttribute($attribute); - if (!empty($attribute_value)) { + if ($attribute_value !== '') { $attribute_found = TRUE; if ($is_exact) { if ($attribute_value === (string) $value) { @@ -984,10 +986,10 @@ protected function elementAssertStackingOrder(string $selector1, string $selecto * The CSS selector of the second element. * * @return string - * A pipe-delimited string of the order (`1` when the first element stacks - * above the second one, `-1` when it stacks below it, `0` when both - * selectors match the same element), the effective z-index of each - * compared participant, and the basis of the comparison. + * A pipe-delimited string of the order, the effective z-index of each + * compared participant, and the basis of the comparison. The order is `1` + * when the first element stacks above the second one, `-1` when it stacks + * below it, and `0` when both selectors match the same element. */ protected function elementResolveStackingOrder(string $selector1, string $selector2): string { $selector1_js = json_encode($selector1, JSON_UNESCAPED_SLASHES); @@ -1136,7 +1138,7 @@ protected function elementAssertPinnedToTopWithin(string $selector, int $toleran $script = 'var rect = {{ELEMENT}}.getBoundingClientRect(); return rect.top + "|" + rect.height;'; [$top, $height] = explode('|', (string) $this->elementExecuteJs($selector, $script), 2); - // An element that is not rendered reports a zero-sized box at the origin, + // An element that is not rendered reports a 0 by 0 box at the origin, // which would otherwise read as pinned. if ((float) $height <= 0) { if ($is_inverted) { diff --git a/src/Steps/Web/FieldTrait.php b/src/Steps/Web/FieldTrait.php index 61f80887e..36f3731c5 100644 --- a/src/Steps/Web/FieldTrait.php +++ b/src/Steps/Web/FieldTrait.php @@ -29,7 +29,7 @@ * - Assert field existence, state, and selected options. * - Support for specialized widgets like color pickers and rich text editors. * - Disable browser validation for forms with deferred execution. - * - Use @disable-form-validation tag to automatically disable validation for all forms. + * - The @disable-form-validation tag disables validation for all forms. * * Skip processing with tag: `@behat-steps-skip:FieldTrait` * @@ -141,9 +141,9 @@ public function fieldDisableFormBrowserValidation(string $selector): void { /** * Fill in a multi-value field widget with a list of values. * - * Locates the field wrapper by label, counts existing rows, clicks - * "Add another item" as many times as needed (waiting for AJAX between - * clicks), and fills each row in order. + * Locates the field wrapper by label and counts existing rows. "Add another + * item" is clicked as many times as needed, waiting for AJAX between clicks, + * and each row is filled in order. * * Requires a JavaScript-capable browser driver because the "Add another * item" button relies on AJAX. @@ -171,12 +171,11 @@ public function fieldFillMultiValue(string $field, TableNode $table): void { $page = $this->getSession()->getPage(); - // Drupal multi-value widgets wrap the field rows (a table) and the - // "Add another item" button in an outer container identified by - // `data-drupal-selector="edit--wrapper"`. The title can appear - // in a nested