diff --git a/.ahoy.yml b/.ahoy.yml index 59260df..4aa7311 100644 --- a/.ahoy.yml +++ b/.ahoy.yml @@ -110,19 +110,19 @@ commands: lint: usage: Lint code. cmd: | - ahoy cli "cp -Rf phpcs.xml phpstan.neon phpmd.xml gherkinlint.json build > /dev/null" \ + ahoy cli "cp -Rf phpcs.xml phpstan.neon phpmd.xml gherkinlint.json rector.php build > /dev/null" \ && ahoy cli "cd /app/build && ./vendor/bin/phpcs" \ + && ahoy cli "cd /app/build && ./vendor/bin/phpstan" \ + && ahoy cli "cd /app/build && ./vendor/bin/rector --clear-cache --dry-run" \ && ahoy cli "cd /app/build && ./vendor/bin/phpmd ../src text phpmd.xml" \ && ahoy cli "cd /app/build && ./vendor/bin/phpmd ../tests/behat/bootstrap text phpmd.xml" \ - && ahoy cli "cd /app/build && ./vendor/bin/phpstan" \ - && ahoy cli "cd /app/build && ./vendor/bin/rector process ../src --clear-cache --dry-run" \ && ahoy cli "cd /app/build && ./vendor/bin/gherkinlint lint ../tests/behat/features" lint-fix: usage: Fix code. cmd: | - ahoy cli "cp -Rf phpcs.xml phpstan.neon build > /dev/null" \ - && ahoy cli "cd /app/build && ./vendor/bin/rector process ../src --clear-cache" \ + ahoy cli "cp -Rf phpcs.xml phpstan.neon phpmd.xml gherkinlint.json rector.php build > /dev/null" \ + && ahoy cli "cd /app/build && ./vendor/bin/rector --clear-cache" \ && ahoy cli "cd /app/build && ./vendor/bin/phpcbf" test-bdd: diff --git a/composer.json b/composer.json index bae2827..951d6dc 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "source": "https://github.com/drevops/behat-steps" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "behat/behat": "^3", "behat/mink": ">=1.11", "behat/mink-selenium2-driver": ">=1.7", diff --git a/phpcs.xml b/phpcs.xml index 78d4fa4..6dd7213 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,8 @@ + + @@ -11,6 +13,11 @@ + + + + + ../src ../tests/behat/bootstrap diff --git a/phpstan.neon b/phpstan.neon index e57d78b..25b174c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,7 +4,9 @@ parameters: - level: 5 + level: 7 + + phpVersion: 80224 paths: - ../src @@ -13,6 +15,7 @@ parameters: excludePaths: - vendor/* - node_modules/* + - ../tests/behat/bootstrap/BehatCliContext.php scanFiles: - vendor/behat/behat/bin/behat diff --git a/tests/behat/fixtures/d10/rector.php b/rector.php similarity index 82% rename from tests/behat/fixtures/d10/rector.php rename to rector.php index 0e8db3c..f55c146 100644 --- a/tests/behat/fixtures/d10/rector.php +++ b/rector.php @@ -12,7 +12,7 @@ declare(strict_types=1); -use DrupalFinder\DrupalFinder; +use DrupalFinder\DrupalFinderComposerRuntime; use DrupalRector\Set\Drupal10SetList; use DrupalRector\Set\Drupal8SetList; use DrupalRector\Set\Drupal9SetList; @@ -23,11 +23,28 @@ use Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector; +use Rector\Php80\Rector\Switch_\ChangeSwitchToMatchRector; +use Rector\Php81\Rector\Array_\FirstClassCallableRector; use Rector\Set\ValueObject\SetList; use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; return static function (RectorConfig $rectorConfig): void { + $drupalFinder = new DrupalFinderComposerRuntime(); + $drupalRoot = $drupalFinder->getDrupalRoot(); + + $rectorConfig->autoloadPaths([ + $drupalRoot . '/core', + $drupalRoot . '/modules', + $drupalRoot . '/themes', + $drupalRoot . '/profiles', + ]); + + $rectorConfig->paths([ + $drupalRoot . '/../../src', + $drupalRoot . '/../../tests/behat/bootstrap', + ]); + $rectorConfig->sets([ // Provided by Rector. SetList::PHP_80, @@ -46,21 +63,12 @@ $rectorConfig->rule(DeclareStrictTypesRector::class); - $drupalFinder = new DrupalFinder(); - $drupalFinder->locateRoot(__DIR__); - - $drupalRoot = $drupalFinder->getDrupalRoot(); - $rectorConfig->autoloadPaths([ - $drupalRoot . '/core', - $drupalRoot . '/modules', - $drupalRoot . '/themes', - $drupalRoot . '/profiles', - ]); - $rectorConfig->skip([ // Rules added by Rector's rule sets. + ChangeSwitchToMatchRector::class, CountArrayToEmptyArrayComparisonRector::class, DisallowedEmptyRuleFixerRector::class, + FirstClassCallableRector::class, InlineArrayReturnAssignRector::class, NewlineAfterStatementRector::class, NewlineBeforeNewAssignSetRector::class, @@ -69,21 +77,7 @@ // Dependencies. '*/vendor/*', '*/node_modules/*', - // Core and contribs. - '*/core/*', - '*/modules/contrib/*', - '*/themes/contrib/*', - '*/profiles/contrib/*', - '*/sites/default/default.settings.php', - // Files. - '*/sites/default/files/*', - '*/sites/simpletest/*', - // Scaffold files. - '*/autoload.php', - '*/index.php', - '*/update.php', - // Composer scripts. - '*/scripts/composer/*', + $drupalRoot . '/../../tests/behat/bootstrap/BehatCliContext.php', ]); $rectorConfig->fileExtensions([ diff --git a/src/ContentTrait.php b/src/ContentTrait.php index f974081..baa293e 100644 --- a/src/ContentTrait.php +++ b/src/ContentTrait.php @@ -41,7 +41,7 @@ public function contentRemoveContentType(string $type): void { * @When I visit :type :title */ public function contentVisitPageWithTitle(string $type, string $title): void { - $nids = $this->contentNodeLoadMultiple($type, [ + $nids = $this->contentLoadMultiple($type, [ 'title' => $title, ]); @@ -67,7 +67,7 @@ public function contentVisitPageWithTitle(string $type, string $title): void { * @When I edit :type :title */ public function contentEditPageWithTitle(string $type, string $title): void { - $nids = $this->contentNodeLoadMultiple($type, [ + $nids = $this->contentLoadMultiple($type, [ 'title' => $title, ]); @@ -87,7 +87,7 @@ public function contentEditPageWithTitle(string $type, string $title): void { * @When I delete :type :title */ public function contentDeletePageWithTitle(string $type, string $title): void { - $nids = $this->contentNodeLoadMultiple($type, [ + $nids = $this->contentLoadMultiple($type, [ 'title' => $title, ]); @@ -115,7 +115,7 @@ public function contentDeletePageWithTitle(string $type, string $title): void { */ public function contentDelete(string $type, TableNode $nodesTable): void { foreach ($nodesTable->getHash() as $nodeHash) { - $nids = $this->contentNodeLoadMultiple($type, $nodeHash); + $nids = $this->contentLoadMultiple($type, $nodeHash); $controller = \Drupal::entityTypeManager()->getStorage('node'); $entities = $controller->loadMultiple($nids); @@ -161,7 +161,7 @@ public function contentModeratePageWithTitle(string $type, string $title, string * @When I visit :type :title scheduled transitions */ public function contentVisitScheduledTransitionsPageWithTitle(string $type, string $title): void { - $nids = $this->contentNodeLoadMultiple($type, [ + $nids = $this->contentLoadMultiple($type, [ 'title' => $title, ]); @@ -180,13 +180,13 @@ public function contentVisitScheduledTransitionsPageWithTitle(string $type, stri * * @param string $type * The node type. - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of node ids. */ - protected function contentNodeLoadMultiple(string $type, array $conditions = []) { + protected function contentLoadMultiple(string $type, array $conditions = []): array { $query = \Drupal::entityQuery('node') ->accessCheck(FALSE) ->condition('type', $type); diff --git a/src/CookieTrait.php b/src/CookieTrait.php index 92ce5d0..5bc6c7b 100644 --- a/src/CookieTrait.php +++ b/src/CookieTrait.php @@ -18,7 +18,7 @@ trait CookieTrait { * * @Then a cookie with( the) name :name should exist */ - public function cookieWithNameShouldExist($name): void { + public function cookieWithNameShouldExist(string $name): void { static::cookieExists($name); } @@ -27,7 +27,7 @@ public function cookieWithNameShouldExist($name): void { * * @Then a cookie with( the) name :name and value :value should exist */ - public function cookieWithNameValueShouldExist($name, $value): void { + public function cookieWithNameValueShouldExist(string $name, string $value): void { static::cookieExists($name, $value); } @@ -36,7 +36,7 @@ public function cookieWithNameValueShouldExist($name, $value): void { * * @Then a cookie with( the) name :name and value containing :partial_value should exist */ - public function cookieWithNamePartialValueShouldExist($name, $partial_value): void { + public function cookieWithNamePartialValueShouldExist(string $name, string $partial_value): void { static::cookieExists($name, $partial_value, FALSE, TRUE); } @@ -45,7 +45,7 @@ public function cookieWithNamePartialValueShouldExist($name, $partial_value): vo * * @Then a cookie with( the) name containing :partial_name should exist */ - public function cookieWithPartialNameShouldExist($partial_name): void { + public function cookieWithPartialNameShouldExist(string $partial_name): void { static::cookieExists($partial_name, NULL, TRUE); } @@ -54,7 +54,7 @@ public function cookieWithPartialNameShouldExist($partial_name): void { * * @Then a cookie with( the) name containing :partial_name and value :value should exist */ - public function cookieWithPartialNameValueShouldExist($partial_name, $value): void { + public function cookieWithPartialNameValueShouldExist(string $partial_name, string $value): void { static::cookieExists($partial_name, $value, TRUE); } @@ -63,7 +63,7 @@ public function cookieWithPartialNameValueShouldExist($partial_name, $value): vo * * @Then a cookie with( the) name containing :partial_name and value containing :partial_value should exist */ - public function cookieWithPartialNamePartialValueShouldExist($partial_name, $partial_value): void { + public function cookieWithPartialNamePartialValueShouldExist(string $partial_name, string $partial_value): void { static::cookieExists($partial_name, $partial_value, TRUE, TRUE); } @@ -72,7 +72,7 @@ public function cookieWithPartialNamePartialValueShouldExist($partial_name, $par * * @Then a cookie with( the) name :name should not exist */ - public function cookieWithNameShouldNotExist($name): void { + public function cookieWithNameShouldNotExist(string $name): void { static::cookieNotExists($name); } @@ -81,7 +81,7 @@ public function cookieWithNameShouldNotExist($name): void { * * @Then a cookie with( the) name :name and value :value should not exist */ - public function cookieWithNameValueShouldNotExist($name, $value): void { + public function cookieWithNameValueShouldNotExist(string $name, string $value): void { static::cookieNotExists($name, $value); } @@ -90,7 +90,7 @@ public function cookieWithNameValueShouldNotExist($name, $value): void { * * @Then a cookie with( the) name :name and value containing :partial_value should not exist */ - public function cookieWithNamePartialValueShouldNotExist($name, $partial_value): void { + public function cookieWithNamePartialValueShouldNotExist(string $name, string $partial_value): void { static::cookieNotExists($name, $partial_value, FALSE, TRUE); } @@ -99,7 +99,7 @@ public function cookieWithNamePartialValueShouldNotExist($name, $partial_value): * * @Then a cookie with( the) name containing :partial_name should not exist */ - public function cookieWithPartialNameShouldNotExist($partial_name): void { + public function cookieWithPartialNameShouldNotExist(string $partial_name): void { static::cookieNotExists($partial_name, NULL, TRUE); } @@ -108,7 +108,7 @@ public function cookieWithPartialNameShouldNotExist($partial_name): void { * * @Then a cookie with( the) name containing :partial_name and value :value should not exist */ - public function cookieWithPartialNameValueShouldNotExist($partial_name, $value): void { + public function cookieWithPartialNameValueShouldNotExist(string $partial_name, string $value): void { static::cookieNotExists($partial_name, $value, TRUE); } @@ -117,14 +117,14 @@ public function cookieWithPartialNameValueShouldNotExist($partial_name, $value): * * @Then a cookie with( the) name containing :partial_name and value containing :partial_value should not exist */ - public function cookieWithPartialNamePartialValueShouldNotExist($partial_name, $partial_value): void { + public function cookieWithPartialNamePartialValueShouldNotExist(string $partial_name, string $partial_value): void { static::cookieNotExists($partial_name, $partial_value, TRUE, TRUE); } /** * Check if a cookie exists. */ - protected function cookieExists($name, $value = NULL, $is_partial_name = FALSE, $is_partial_value = FALSE): void { + protected function cookieExists(string $name, ?string $value = NULL, bool $is_partial_name = FALSE, bool $is_partial_value = FALSE): void { $cookie = $this->cookieGetByName($name, $is_partial_name); if ($cookie === NULL) { @@ -137,7 +137,7 @@ protected function cookieExists($name, $value = NULL, $is_partial_name = FALSE, if ($value !== NULL) { if ($is_partial_value) { - if (!str_contains((string) $cookie['value'], (string) $value)) { + if (!str_contains((string) $cookie['value'], $value)) { if ($is_partial_name) { throw new \Exception(sprintf('The cookie with name containing "%s" was set with value "%s", but it should contain "%s"', $name, $cookie['value'], $value)); } @@ -156,7 +156,7 @@ protected function cookieExists($name, $value = NULL, $is_partial_name = FALSE, /** * Check if a cookie does not exist. */ - protected function cookieNotExists($name, $value = NULL, $is_partial_name = FALSE, $is_partial_value = FALSE): void { + protected function cookieNotExists(string $name, ?string $value = NULL, bool $is_partial_name = FALSE, bool $is_partial_value = FALSE): void { $cookie = $this->cookieGetByName($name, $is_partial_name); if ($cookie === NULL) { @@ -165,7 +165,7 @@ protected function cookieNotExists($name, $value = NULL, $is_partial_name = FALS if ($value !== NULL) { if ($is_partial_value) { - if (str_contains((string) $cookie['value'], (string) $value)) { + if (str_contains((string) $cookie['value'], $value)) { if ($is_partial_name) { throw new \Exception(sprintf('The cookie with name containing "%s" was set with value containing "%s", but it should not contain "%s"', $name, $cookie['value'], $value)); } @@ -189,13 +189,21 @@ protected function cookieNotExists($name, $value = NULL, $is_partial_name = FALS /** * Get a cookie by exact or partial name. + * + * @param string $name + * The name of the cookie. + * @param bool $is_partial + * Whether to search for a partial name. + * + * @return array|null + * The cookie or NULL if not found. */ - protected function cookieGetByName($name, $is_partial = FALSE): ?array { + protected function cookieGetByName(string $name, bool $is_partial = FALSE): ?array { $cookies = self::cookieGetAll(); foreach ($cookies as $cookie) { if ($is_partial) { - if (str_contains((string) $cookie['name'], (string) $name)) { + if (str_contains((string) $cookie['name'], $name)) { return $cookie; } } @@ -209,6 +217,9 @@ protected function cookieGetByName($name, $is_partial = FALSE): ?array { /** * Get all cookies. + * + * @return array> + * An array of cookies. */ protected function cookieGetAll(): array { $driver = $this->getSession()->getDriver(); @@ -257,7 +268,10 @@ protected function cookieGetAll(): array { continue; } - $cookies = self::cookieParseHeader($header_value); + // Only support parsed cookies from a string header. + if (is_string($header_value)) { + $cookies = self::cookieParseHeader($header_value); + } break; } @@ -266,6 +280,12 @@ protected function cookieGetAll(): array { /** * Parse a cookie header string. + * + * @param string $string + * The cookie header string. + * + * @return array> + * An array of cookies. */ protected static function cookieParseHeader(string $string): array { $cookies = []; diff --git a/src/DateTrait.php b/src/DateTrait.php index 37b7d34..d0727df 100644 --- a/src/DateTrait.php +++ b/src/DateTrait.php @@ -21,7 +21,7 @@ trait DateTrait { * @Transform :value * @Transform :expectedValue */ - public function dateRelativeTransformValue(string $value): string|array|null { + public function dateRelativeTransformValue(string $value): string { return static::dateRelativeProcessValue($value); } @@ -79,7 +79,7 @@ protected static function dateRelativeStringHasToken(string $string): bool { * To avoid this, default time is always rounded to midday and it is expected * that relative time within a day use max of 12 hours offset. */ - public static function dateRelativeProcessValue(string $value, ?int $now = NULL): string|array|null { + public static function dateRelativeProcessValue(string $value, ?int $now = NULL): string { // Inexpensive token detection and early exit. if (!static::dateRelativeStringHasToken($value)) { return $value; @@ -88,12 +88,14 @@ public static function dateRelativeProcessValue(string $value, ?int $now = NULL) // If `now` is not provided, round to the current hour to make sure that // assertions are running within the same timeframe (for long tests). $now = $now ?: strtotime(date('Y-m-d H:i:00', self::dateNow())); + $now = $now ?: NULL; - return preg_replace_callback('/\[([relative:]+):([^]\[#]+)(?:#([^]\[]+))?]/', function (array $matches) use ($now) { + return (string) preg_replace_callback('/\[([relative:]+):([^]\[#]+)(?:#([^]\[]+))?]/', function (array $matches) use ($now): string { $timestamp = strtotime($matches[2], $now); if ($timestamp === FALSE) { throw new \RuntimeException(sprintf('The supplied relative date cannot be evaluated: "%s"', $matches[1])); } + // Convert to date format, if provided. if (isset($matches[3])) { $timestamp = date($matches[3], $timestamp); @@ -103,14 +105,14 @@ public static function dateRelativeProcessValue(string $value, ?int $now = NULL) throw new \RuntimeException(sprintf('The supplied relative date cannot be evaluated: "%s"', $matches[1])); } - return $timestamp; + return (string) $timestamp; }, $value); } /** * Get the current timestamp. */ - public static function dateNow(): int { + protected static function dateNow(): int { return time(); } diff --git a/src/DraggableViewsTrait.php b/src/DraggableViewsTrait.php index d9e0e04..a0a42a9 100644 --- a/src/DraggableViewsTrait.php +++ b/src/DraggableViewsTrait.php @@ -64,8 +64,16 @@ public function draggableViewsSaveBundleOrder(string $view_id, string $view_disp /** * Find a node using provided conditions. + * + * @param string $type + * The node type. + * @param array $conditions + * The conditions to search for. + * + * @return \Drupal\node\NodeInterface|null + * The found node or NULL. */ - protected function draggableViewsFindNode(string $type, array $conditions): NodeInterface|NULL { + protected function draggableViewsFindNode(string $type, array $conditions): ?NodeInterface { $query = \Drupal::entityQuery('node') ->accessCheck(FALSE) ->condition('type', $type); diff --git a/src/EckTrait.php b/src/EckTrait.php index 66ce29d..1787327 100644 --- a/src/EckTrait.php +++ b/src/EckTrait.php @@ -6,6 +6,7 @@ use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; +use Drupal\eck\EckEntityInterface; /** * Trait EckTrait. @@ -19,7 +20,7 @@ trait EckTrait { /** * Custom eck content entities organised by entity type. * - * @var array + * @var array> */ protected $eckEntities = []; @@ -52,7 +53,7 @@ public function eckEntitiesCreate(string $bundle, string $entity_type, TableNode */ public function eckDeleteEntities(string $bundle, string $entity_type, TableNode $table): void { foreach ($table->getHash() as $nodeHash) { - $entity_ids = $this->eckEntityLoadMultiple($entity_type, $bundle, $nodeHash); + $entity_ids = $this->eckLoadMultiple($entity_type, $bundle, $nodeHash); $controller = \Drupal::entityTypeManager()->getStorage($entity_type); $entities = $controller->loadMultiple($entity_ids); @@ -75,8 +76,9 @@ public function eckEntitiesCleanAll(AfterScenarioScope $scope): void { $entity_ids_by_type = []; foreach ($this->eckEntities as $entity_type => $content_entities) { + /** @var \Drupal\eck\EckEntityInterface $content_entity */ foreach ($content_entities as $content_entity) { - $entity_ids_by_type[$entity_type][] = $content_entity->id; + $entity_ids_by_type[$entity_type][] = $content_entity->id(); } } @@ -96,13 +98,13 @@ public function eckEntitiesCleanAll(AfterScenarioScope $scope): void { * The entity type. * @param string $bundle * The entity bundle. - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of entity ids. */ - protected function eckEntityLoadMultiple(string $entity_type, string $bundle, array $conditions = []) { + protected function eckLoadMultiple(string $entity_type, string $bundle, array $conditions = []): array { $query = \Drupal::entityQuery($entity_type) ->accessCheck(FALSE) ->condition('type', $bundle); @@ -126,7 +128,7 @@ protected function eckEntityLoadMultiple(string $entity_type, string $bundle, ar * @param \Behat\Gherkin\Node\TableNode $table * The TableNode of entity data. */ - protected function eckCreateEntities(string $entity_type, string $bundle, TableNode $table) { + protected function eckCreateEntities(string $entity_type, string $bundle, TableNode $table): void { foreach ($table->getHash() as $entity_hash) { $entity = (object) $entity_hash; $entity->type = $bundle; @@ -140,7 +142,9 @@ protected function eckCreateEntities(string $entity_type, string $bundle, TableN protected function eckCreateEntity(string $entity_type, \StdClass $entity): void { $this->parseEntityFields($entity_type, $entity); $saved = $this->getDriver()->createEntity($entity_type, $entity); - $this->eckEntities[$entity_type][] = $saved; + if ($saved instanceof EckEntityInterface) { + $this->eckEntities[$entity_type][] = $saved; + } } /** @@ -154,7 +158,7 @@ protected function eckCreateEntity(string $entity_type, \StdClass $entity): void */ public function eckEditEntityWithTitle(string $bundle, string $entity_type, string $label): void { $entity_type_manager = \Drupal::entityTypeManager(); - $entity_ids = $this->eckEntityLoadMultiple($entity_type, $bundle, [ + $entity_ids = $this->eckLoadMultiple($entity_type, $bundle, [ 'title' => $label, ]); @@ -181,7 +185,7 @@ public function eckEditEntityWithTitle(string $bundle, string $entity_type, stri */ public function eckVisitEntityPageWithTitle(string $bundle, string $entity_type, string $label): void { $entity_type_manager = \Drupal::entityTypeManager(); - $entity_ids = $this->eckEntityLoadMultiple($entity_type, $bundle, [ + $entity_ids = $this->eckLoadMultiple($entity_type, $bundle, [ 'title' => $label, ]); diff --git a/src/EmailTrait.php b/src/EmailTrait.php index 4c86d74..6dcad65 100644 --- a/src/EmailTrait.php +++ b/src/EmailTrait.php @@ -8,6 +8,8 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\PyStringNode; use Drupal\Core\Database\Database; +use Drupal\Core\Database\StatementInterface; +use Drupal\user\UserInterface; /** * Trait EmailTrait. @@ -21,7 +23,7 @@ trait EmailTrait { /** * List of email service types. * - * @var array + * @var array */ protected $emailTypes = []; @@ -154,7 +156,7 @@ public function emailAssertNoEmailsWereSentToAddress(string $address): void { * * @Then an email header :header contains: */ - public function emailAssertEmailHeadersContains(string $header, PyStringNode $string, bool $exact = FALSE): array { + public function emailAssertEmailHeaderContains(string $header, PyStringNode $string, bool $exact = FALSE): void { $string_value = (string) $string; $string_value = $exact ? $string_value : trim((string) preg_replace('/\s+/', ' ', $string_value)); @@ -163,7 +165,7 @@ public function emailAssertEmailHeadersContains(string $header, PyStringNode $st $header_value = $exact ? $header_value : trim((string) preg_replace('/\s+/', ' ', (string) $header_value)); if (str_contains((string) $header_value, $string_value)) { - return $record; + return; } } @@ -175,8 +177,8 @@ public function emailAssertEmailHeadersContains(string $header, PyStringNode $st * * @Then an email header :header contains exact: */ - public function emailAssertEmailHeadersContainsExact(string $header, PyStringNode $string): void { - $this->emailAssertEmailHeadersContains($header, $string, TRUE); + public function emailAssertEmailHeaderContainsExact(string $header, PyStringNode $string): void { + $this->emailAssertEmailHeaderContains($header, $string, TRUE); } /** @@ -186,12 +188,13 @@ public function emailAssertEmailHeadersContainsExact(string $header, PyStringNod */ public function emailAssertEmailToUserIsActionWithContent(string $name, string $action, string $field, PyStringNode $string): void { $user = $name === 'current' && !empty($this->getUserManager()->getCurrentUser()) ? $this->getUserManager()->getCurrentUser() : user_load_by_name($name); - if (!$user) { + + if (!$user instanceof UserInterface) { throw new \Exception(sprintf('Unable to find a user "%s"', $name)); } if ($action === 'sent') { - $this->emailAssertEmailContains('to', new PyStringNode([$user->mail], 0), TRUE); + $this->emailAssertEmailContains('to', new PyStringNode([$user->getEmail()], 0), TRUE); $this->emailAssertEmailContains($field, $string); } elseif ($action === 'not sent') { @@ -208,24 +211,12 @@ public function emailAssertEmailToUserIsActionWithContent(string $name, string $ * @Then an email :field contains * @Then an email :field contains: */ - public function emailAssertEmailContains(string $field, PyStringNode $string, bool $exact = FALSE): array { - if (!in_array($field, ['subject', 'body', 'to', 'from'])) { - throw new \RuntimeException(sprintf('Invalid email field %s was specified for assertion', $field)); - } - - $string = strval($string); - $string = $exact ? $string : trim((string) preg_replace('/\s+/', ' ', $string)); + public function emailAssertEmailContains(string $field, PyStringNode $string, bool $exact = FALSE): void { + $email = $this->emailFind($field, $string, $exact); - foreach (self::emailGetCollectedEmails() as $record) { - $field_string = $record[$field] ?? ''; - $field_string = $exact ? $field_string : trim((string) preg_replace('/\s+/', ' ', (string) $field_string)); - - if (str_contains((string) $field_string, $string)) { - return $record; - } + if (!$email) { + throw new \Exception(sprintf('Unable to find email with%s text "%s" in field "%s" retrieved from test email collector.', ($exact ? ' exact' : ''), $string, $field)); } - - throw new \Exception(sprintf('Unable to find email with%s text "%s" in field "%s" retrieved from test email collector.', ($exact ? ' exact' : ''), $string, $field)); } /** @@ -280,8 +271,23 @@ public function emailAssertEmailNotContainsExact(string $field, PyStringNode $st public function emailFollowLinkNumber(string $number, PyStringNode $subject): void { $number = intval($number); - $email = $this->emailAssertEmailContains('subject', $subject); - $links = self::emailExtractLinks($email['params']['body'] ?? $email['body'] ?? ''); + $email = $this->emailFind('subject', $subject); + + if (!$email) { + throw new \Exception(sprintf('Unable to find email with subject "%s" retrieved from test email collector.', $subject)); + } + + if (isset($email['params']['body']) && is_string($email['params']['body'])) { + $body = $email['params']['body']; + } + elseif (is_string($email['body'])) { + $body = $email['body']; + } + else { + throw new \Exception('No body found in email'); + } + + $links = self::emailExtractLinks($body); if (empty($links)) { throw new \Exception(sprintf('No links were found in the email with subject %s', $subject)); @@ -303,11 +309,17 @@ public function emailFollowLinkNumber(string $number, PyStringNode $subject): vo * @Then file :name attached to the email with the subject: */ public function emailAssertEmailContainsAttachmentWithName(string $name, PyStringNode $subject): void { - $email = $this->emailAssertEmailContains('subject', $subject); + $email = $this->emailFind('subject', $subject); - foreach ($email['params']['attachments'] as $attachment) { - if ($attachment['filename'] == $name) { - return; + if (!$email) { + throw new \Exception(sprintf('Unable to find email with subject "%s" retrieved from test email collector.', $subject)); + } + + if (!empty($email['params']['attachments'])) { + foreach ($email['params']['attachments'] as $attachment) { + if ($attachment['filename'] == $name) { + return; + } } } @@ -397,11 +409,18 @@ protected static function emailDeleteMailSystemOriginal(): void { /** * Get emails collected during the test. + * + * @return array> + * Array of collected emails. */ protected function emailGetCollectedEmails(): array { // Directly read data from the database to avoid cache invalidation that // may corrupt the system under test. - $emails = array_map(unserialize(...), Database::getConnection()->query("SELECT name, value FROM {key_value} WHERE name = 'system.test_mail_collector'")->fetchAllKeyed()); + $query = Database::getConnection()->query("SELECT name, value FROM {key_value} WHERE name = 'system.test_mail_collector'"); + + if ($query instanceof StatementInterface) { + $emails = array_map(unserialize(...), $query->fetchAllKeyed()); + } $emails = empty($emails['system.test_mail_collector']) ? [] : $emails['system.test_mail_collector']; @@ -422,13 +441,46 @@ protected function emailGetCollectedEmails(): array { return $emails; } + /** + * Find an email message field containing a value. + * + * @param string $field + * Field to search in. + * @param \Behat\Gherkin\Node\PyStringNode $string + * String to search for. + * @param bool $exact + * Whether to search for an exact match. + * + * @return array>|null + * Email record or NULL if not found. + */ + protected function emailFind(string $field, PyStringNode $string, bool $exact = FALSE): ?array { + if (!in_array($field, ['subject', 'body', 'to', 'from'])) { + throw new \RuntimeException(sprintf('Invalid email field %s was specified for assertion', $field)); + } + + $string = strval($string); + $string = $exact ? $string : trim((string) preg_replace('/\s+/', ' ', $string)); + + foreach (self::emailGetCollectedEmails() as $record) { + $field_string = $record[$field] ?? ''; + $field_string = $exact ? $field_string : trim((string) preg_replace('/\s+/', ' ', (string) $field_string)); + + if (str_contains((string) $field_string, $string)) { + return $record; + } + } + + return NULL; + } + /** * Extract all links from provided string. * * @param string $string * String to extract links from. * - * @return array + * @return array * Array of extracted links. */ protected static function emailExtractLinks(string $string): array { @@ -445,6 +497,12 @@ protected static function emailExtractLinks(string $string): array { /** * Extract email types from tags. + * + * @param array $tags + * Array of tags. + * + * @return array + * Array of email types. */ protected static function emailExtractTypes(array $tags): array { $types = []; diff --git a/src/FileDownloadTrait.php b/src/FileDownloadTrait.php index 66a84a6..1bec42c 100644 --- a/src/FileDownloadTrait.php +++ b/src/FileDownloadTrait.php @@ -24,7 +24,7 @@ trait FileDownloadTrait { /** * Information about downloaded file. * - * @var array + * @var array */ protected $fileDownloadDownloadedFileInfo; @@ -147,16 +147,20 @@ public function fileDownloadAssertFileContains(PyStringNode $string): void { if (!$this->fileDownloadDownloadedFileInfo) { throw new \RuntimeException('Downloaded file content has no data.'); } + $lines = preg_split('/\R/', (string) $this->fileDownloadDownloadedFileInfo['content']); - foreach ($lines as $line) { - if (preg_match('/^\/.+\/[a-z]*$/i', $string)) { - if (preg_match($string, $line)) { + + if (is_array($lines)) { + foreach ($lines as $line) { + if (preg_match('/^\/.+\/[a-z]*$/i', $string)) { + if (preg_match($string, $line)) { + return; + } + } + elseif (str_contains($line, $string)) { return; } } - elseif (str_contains($line, $string)) { - return; - } } throw new \Exception('Unable to find a content line with searched string.'); @@ -255,6 +259,14 @@ protected function fileDownloadOpenZip(): \ZipArchive { /** * Download file. + * + * @param string $url + * URL to download file from. + * @param array $options + * CURL options. + * + * @return array + * Array of downloaded file information. */ protected function fileDownloadProcess(string $url, array $options = []): array { $response_headers = []; @@ -295,11 +307,17 @@ protected function fileDownloadProcess(string $url, array $options = []): array // Resolve file path and name. $dir = $this->fileDownloadGetTempDir(); + // Try to extract name from the download string. $url_file_name = parse_url($url, PHP_URL_PATH); $url_file_name = $url_file_name ? basename($url_file_name) : $url_file_name; $headers['file_name'] = empty($headers['file_name']) && !empty($url_file_name) ? $url_file_name : $headers['file_name']; + $file_path = empty($headers['file_name']) ? tempnam($dir, 'behat') : $dir . DIRECTORY_SEPARATOR . $headers['file_name']; + if (!$file_path) { + throw new \RuntimeException('Unable to create temp file for downloaded content'); + } + $file_name = basename($file_path); // Write file contents. @@ -316,14 +334,15 @@ protected function fileDownloadProcess(string $url, array $options = []): array /** * Extract downloaded file information from the response headers. * - * @param array $headers + * @param array $headers * Array of headers from CURL. * - * @return array + * @return array * Array of parsed headers, if any. */ protected function fileDownloadParseHeaders(array $headers): array { $parsed_headers = []; + foreach ($headers as $header) { if (preg_match('/Content-Disposition:\s*attachment;\s*filename\s*=\s*\"([^"]+)"/', (string) $header, $matches) && !empty($matches[1])) { $parsed_headers['file_name'] = trim($matches[1]); @@ -332,7 +351,6 @@ protected function fileDownloadParseHeaders(array $headers): array { if (preg_match('/Content-Type:\s*(.+)/', (string) $header, $matches) && !empty($matches[1])) { $parsed_headers['content_type'] = trim($matches[1]); - continue; } } diff --git a/src/FileTrait.php b/src/FileTrait.php index 3b3c0be..f2590c5 100644 --- a/src/FileTrait.php +++ b/src/FileTrait.php @@ -22,16 +22,16 @@ trait FileTrait { /** - * Files ids. + * Files entities. * - * @var array + * @var array */ - protected $files = []; + protected $fileEntities = []; /** * Unmanaged file URIs. * - * @var array + * @var array */ protected $filesUnmanagedUris = []; @@ -77,23 +77,34 @@ public function fileCreateManaged(TableNode $nodesTable): void { protected function fileCreateManagedSingle(\StdClass $stub): FileInterface { $this->parseEntityFields('file', $stub); $saved = $this->fileCreateEntity($stub); - $this->files[] = $saved; + + $this->fileEntities[] = $saved; return $saved; } /** * Create file entity. + * + * @param \StdClass $stub + * Stub object. + * + * @return \Drupal\file\FileInterface + * Created file entity. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function fileCreateEntity(\StdClass $stub): FileInterface { if (empty($stub->path)) { throw new \RuntimeException('"path" property is required'); } + $path = ltrim($stub->path, '/'); // Get fixture file path. - if ($this->getMinkParameter('files_path')) { - $full_path = rtrim(realpath($this->getMinkParameter('files_path')), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path; + if (!empty($this->getMinkParameter('files_path'))) { + $full_path = rtrim((string) realpath($this->getMinkParameter('files_path')), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path; if (is_file($full_path)) { $path = $full_path; } @@ -113,7 +124,12 @@ protected function fileCreateEntity(\StdClass $stub): FileInterface { } } - $entity = \Drupal::service('file.repository')->writeData(file_get_contents($path), $destination, FileExists::Replace); + $content = file_get_contents($path); + if ($content === FALSE) { + throw new \RuntimeException('Unable to read file ' . $path); + } + + $entity = \Drupal::service('file.repository')->writeData($content, $destination, FileExists::Replace); $fields = get_object_vars($stub); foreach ($fields as $property => $value) { @@ -141,7 +157,7 @@ public function fileCleanAll(AfterScenarioScope $scope): void { return; } - foreach ($this->files as $file) { + foreach ($this->fileEntities as $file) { $file->delete(); } @@ -149,7 +165,7 @@ public function fileCleanAll(AfterScenarioScope $scope): void { @unlink($uri); } - $this->files = []; + $this->fileEntities = []; } /** @@ -177,11 +193,19 @@ public function fileCleanAll(AfterScenarioScope $scope): void { */ public function fileDeleteManagedFiles(TableNode $nodesTable): void { $storage = \Drupal::entityTypeManager()->getStorage('file'); + $field_values = $nodesTable->getColumn(0); // Get field name of the column header. $field_name = array_shift($field_values); + + if (is_numeric($field_name)) { + throw new \RuntimeException('The first column should be the field name'); + } + + $field_name = (string) $field_name; + foreach ($field_values as $field_value) { - $ids = $this->fileLoadMultiple([$field_name => $field_value]); + $ids = $this->fileLoadMultiple([$field_name => (string) $field_value]); $entities = $storage->loadMultiple($ids); $storage->delete($entities); } @@ -190,14 +214,15 @@ public function fileDeleteManagedFiles(TableNode $nodesTable): void { /** * Load multiple files with specified conditions. * - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of file ids. */ - protected function fileLoadMultiple(array $conditions = []): array|int { + protected function fileLoadMultiple(array $conditions = []): array { $query = \Drupal::entityQuery('file')->accessCheck(FALSE); + foreach ($conditions as $k => $v) { $and = $query->andConditionGroup(); $and->condition($k, $v); @@ -267,6 +292,9 @@ public function fileAssertUnmanagedHasContent(string $uri, string $content): voi $this->fileAssertUnmanagedExists($uri); $file_content = @file_get_contents($uri); + if ($file_content === FALSE) { + throw new \Exception(sprintf('Unable to read file %s.', $uri)); + } if (!str_contains($file_content, $content)) { throw new \Exception(sprintf('File contents "%s" does not contain "%s".', $file_content, $content)); @@ -282,6 +310,9 @@ public function fileAssertUnmanagedHasNoContent(string $uri, string $content): v $this->fileAssertUnmanagedExists($uri); $file_content = @file_get_contents($uri); + if ($file_content === FALSE) { + throw new \Exception(sprintf('Unable to read file %s.', $uri)); + } if (str_contains($file_content, $content)) { throw new \Exception(sprintf('File contents "%s" contains "%s", but should not.', $file_content, $content)); diff --git a/src/KeyboardTrait.php b/src/KeyboardTrait.php index 3715a54..6e4f253 100644 --- a/src/KeyboardTrait.php +++ b/src/KeyboardTrait.php @@ -140,7 +140,7 @@ public function keyboardPressKeyOnElement(string $char, ?string $selector = NULL * @throws \Behat\Mink\Exception\UnsupportedDriverActionException * If method is used for invalid driver. */ - protected function keyboardTriggerKey(string $xpath, string $key) { + protected function keyboardTriggerKey(string $xpath, string $key): void { $driver = $this->getSession()->getDriver(); if (!$driver instanceof Selenium2Driver) { throw new UnsupportedDriverActionException('Method can be used only with Selenium2 driver', $driver); diff --git a/src/LinkTrait.php b/src/LinkTrait.php index d76baeb..71aaaf6 100644 --- a/src/LinkTrait.php +++ b/src/LinkTrait.php @@ -182,7 +182,7 @@ public function assertLinkNotAbsolute(string $text): void { /** * Returns fixed step argument (with \\" replaced back to "). */ - protected function linkFixStepArgument(string $argument): string|array { + protected function linkFixStepArgument(string $argument): string { return str_replace('\\"', '"', $argument); } diff --git a/src/MediaTrait.php b/src/MediaTrait.php index d18d915..d73424b 100644 --- a/src/MediaTrait.php +++ b/src/MediaTrait.php @@ -7,6 +7,7 @@ use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; use Drupal\media\Entity\Media; +use Drupal\media\MediaInterface; /** * Trait MediaTrait. @@ -21,9 +22,9 @@ trait MediaTrait { /** * Array of created media entities. * - * @var array + * @var array */ - protected $media = []; + protected $mediaEntities = []; /** * Remove any created media items. @@ -36,10 +37,10 @@ public function mediaClean(AfterScenarioScope $scope): void { return; } - foreach ($this->media as $media) { + foreach ($this->mediaEntities as $media) { $media->delete(); } - $this->media = []; + $this->mediaEntities = []; } /** @@ -90,31 +91,66 @@ public function mediaCreate(string $type, TableNode $nodesTable): void { * * @Given /^no ([a-zA-z0-9_-]+) media:$/ */ - public function mediaDelete(string $type, TableNode $nodesTable): void { - foreach ($nodesTable->getHash() as $nodeHash) { - $ids = $this->mediaLoadMultiple($type, $nodeHash); - + public function mediaDelete(string $type, TableNode $table): void { + foreach ($table->getHash() as $node_hash) { + $ids = $this->mediaLoadMultiple($type, $node_hash); $controller = \Drupal::entityTypeManager()->getStorage('media'); $entities = $controller->loadMultiple($ids); $controller->delete($entities); } } + /** + * Navigate to edit media with specified type and name. + * + * @code + * When I edit "document" media "Test document" + * @endcode + * + * @When I edit :type media :name + */ + public function mediaEditWithName(string $type, string $name): void { + $mids = $this->mediaLoadMultiple($type, [ + 'name' => $name, + ]); + + if (empty($mids)) { + throw new \RuntimeException(sprintf('Unable to find %s media "%s"', $type, $name)); + } + + $mid = current($mids); + $path = $this->locatePath('/media/' . $mid) . '/edit'; + print $path; + $this->getSession()->visit($path); + } + /** * Create a single media item. + * + * @param \StdClass $stub + * The media item properties. + * + * @return \Drupal\media\MediaInterface + * The created media item. */ - protected function mediaCreateSingle(\StdClass $stub) { + protected function mediaCreateSingle(\StdClass $stub): MediaInterface { $this->parseEntityFields('media', $stub); $saved = $this->mediaCreateEntity($stub); - $this->media[] = $saved; + $this->mediaEntities[] = $saved; return $saved; } /** * Create media entity. + * + * @param \StdClass $stub + * The media entity properties. + * + * @return \Drupal\media\MediaInterface + * The created media entity. */ - protected function mediaCreateEntity(\StdClass $stub) { + protected function mediaCreateEntity(\StdClass $stub): MediaInterface { // Throw an exception if the media type is missing or does not exist. if (!property_exists($stub, 'bundle') || $stub->bundle === NULL || !$stub->bundle) { throw new \Exception("Cannot create media because it is missing the required property 'bundle'."); @@ -139,6 +175,11 @@ protected function mediaCreateEntity(\StdClass $stub) { * Expand parsed fields into expected field values based on field type. * * This is a re-use of the functionality provided by DrupalExtension. + * + * @param string $entity_type + * The entity type. + * @param \StdClass $stub + * The entity stub. */ protected function mediaExpandEntityFields(string $entity_type, \StdClass $stub): void { $core = $this->getDriver()->getCore(); @@ -152,9 +193,18 @@ protected function mediaExpandEntityFields(string $entity_type, \StdClass $stub) /** * Expand entity fields with fixture values. + * + * @param \StdClass $stub + * The entity stub. */ - protected function mediaExpandEntityFieldsFixtures(\StdClass $stub) { - $fixture_path = $this->getMinkParameter('files_path') ? rtrim(realpath($this->getMinkParameter('files_path')), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : NULL; + protected function mediaExpandEntityFieldsFixtures(\StdClass $stub): void { + if (!empty($this->getMinkParameter('files_path'))) { + $fixture_path = rtrim((string) realpath($this->getMinkParameter('files_path')), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + if (empty($fixture_path) || !is_dir($fixture_path)) { + throw new \RuntimeException('Fixture files path is not set or does not exist. Check that the "files_path" parameter is set for Mink.'); + } $fields = get_object_vars($stub); @@ -178,42 +228,18 @@ protected function mediaExpandEntityFieldsFixtures(\StdClass $stub) { } } - /** - * Navigate to edit media with specified type and name. - * - * @code - * When I edit "document" media "Test document" - * @endcode - * - * @When I edit :type media :name - */ - public function mediaEditWithName(string $type, string $name): void { - $mids = $this->mediaLoadMultiple($type, [ - 'name' => $name, - ]); - - if (empty($mids)) { - throw new \RuntimeException(sprintf('Unable to find %s media "%s"', $type, $name)); - } - - $mid = current($mids); - $path = $this->locatePath('/media/' . $mid) . '/edit'; - print $path; - $this->getSession()->visit($path); - } - /** * Load multiple media entities with specified type and conditions. * * @param string $type * The node type. - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of node ids. */ - protected function mediaLoadMultiple(string $type, array $conditions = []) { + protected function mediaLoadMultiple(string $type, array $conditions = []): array { $query = \Drupal::entityQuery('media') ->accessCheck(FALSE) ->condition('bundle', $type); diff --git a/src/MenuTrait.php b/src/MenuTrait.php index d318b57..9694367 100644 --- a/src/MenuTrait.php +++ b/src/MenuTrait.php @@ -8,6 +8,7 @@ use Behat\Gherkin\Node\TableNode; use Drupal\menu_link_content\Entity\MenuLinkContent; use Drupal\system\Entity\Menu; +use Drupal\system\MenuInterface; /** * Trait MenuTrait. @@ -43,12 +44,10 @@ trait MenuTrait { */ public function menuDelete(TableNode $table): void { foreach ($table->getColumn(0) as $label) { - try { - $menu = $this->loadMenuByLabel($label); + $menu = $this->loadMenuByLabel($label); + if ($menu instanceof MenuInterface) { $menu->delete(); } - catch (\Exception) { - } } } @@ -66,14 +65,16 @@ public function menuDelete(TableNode $table): void { public function menuCreate(TableNode $table): void { foreach ($table->getHash() as $menu_hash) { if (empty($menu_hash['id'])) { - // Create menu id if one not provided. + // Create menu id if one was not provided. $menu_id = strtolower((string) $menu_hash['label']); $menu_id = preg_replace('/[^a-z0-9_]+/', '_', $menu_id); $menu_id = preg_replace('/_+/', '_', (string) $menu_id); $menu_hash['id'] = $menu_id; } + $menu = Menu::create($menu_hash); $menu->save(); + $this->menus[] = $menu; } } @@ -89,12 +90,9 @@ public function menuCreate(TableNode $table): void { */ public function menuLinksDelete(string $menu_name, TableNode $table): void { foreach ($table->getColumn(0) as $title) { - try { - $menu_link = $this->loadMenuLinkByTitle($title, $menu_name); - $menu_link?->delete(); - } - catch (\Exception) { - continue; + $menu_link = $this->loadMenuLinkByTitle($title, $menu_name); + if ($menu_link instanceof MenuLinkContent) { + $menu_link->delete(); } } } @@ -113,6 +111,11 @@ public function menuLinksDelete(string $menu_name, TableNode $table): void { */ public function menuLinksCreate(string $menu_name, TableNode $table): void { $menu = $this->loadMenuByLabel($menu_name); + + if (!$menu instanceof MenuInterface) { + throw new \RuntimeException(sprintf('Menu "%s" not found', $menu_name)); + } + foreach ($table->getHash() as $menu_link_hash) { $menu_link_hash['menu_name'] = $menu->id(); // Add uri to correct property. @@ -142,65 +145,77 @@ public function menuCleanAll(AfterScenarioScope $scope): void { if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { return; } - // Clean up created menus. - foreach ($this->menus as $menu) { - try { - $menu->delete(); - } - catch (\Exception) { - // Ignore the exception and move on. - continue; - } - } - // Clean up menu links. + foreach ($this->menuLinks as $menu_link) { - try { - $menu_link->delete(); - } - catch (\Exception) { - // Ignore the exception and move on. - continue; - } + $menu_link->delete(); } - $this->menuLinks = []; + foreach ($this->menus as $menu) { + $menu->delete(); + } $this->menus = []; } /** * Gets a menu by label. + * + * @param string $label + * The label of the menu. + * + * @return \Drupal\system\MenuInterface|null + * The menu or NULL if not found. */ - protected function loadMenuByLabel(string $label) { + protected function loadMenuByLabel(string $label): ?MenuInterface { /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ $entity_type_manager = \Drupal::getContainer()->get('entity_type.manager'); - $query = $entity_type_manager->getStorage('menu')->getQuery(); - $query->accessCheck(FALSE); - $query->condition('label', $label); - $menu_ids = $query->execute(); - $menu_id = reset($menu_ids); - if ($menu_id === FALSE) { - throw new \Exception(sprintf('Could not find the %s menu.', $label)); + $menu_ids = $entity_type_manager->getStorage('menu')->getQuery() + ->accessCheck(FALSE) + ->condition('label', $label) + ->execute(); + + if (empty($menu_ids)) { + return NULL; } + $menu_id = reset($menu_ids); + return Menu::load($menu_id); } /** * Gets a menu link by title and menu name. + * + * @param string $title + * The title of the menu link. + * @param string $menu_name + * The name of the menu. + * + * @return \Drupal\menu_link_content\Entity\MenuLinkContent|null + * The menu link or NULL if not found. */ - protected function loadMenuLinkByTitle(string $title, string $menu_name) { + protected function loadMenuLinkByTitle(string $title, string $menu_name): ?MenuLinkContent { $menu = $this->loadMenuByLabel($menu_name); + + if (!$menu instanceof MenuInterface) { + return NULL; + } + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ $entity_type_manager = \Drupal::getContainer()->get('entity_type.manager'); - $query = $entity_type_manager->getStorage('menu_link_content')->getQuery(); - $query->accessCheck(FALSE); - $menu_link_ids = $query->condition('menu_name', $menu->id())->condition('title', $title)->execute(); - $menu_link_id = reset($menu_link_ids); - if ($menu_link_id === FALSE) { - throw new \Exception(sprintf('Could not find the %s menu link in %s menu.', $title, $menu_name)); + + $menu_link_ids = $entity_type_manager->getStorage('menu_link_content')->getQuery() + ->accessCheck(FALSE) + ->condition('menu_name', $menu->id()) + ->condition('title', $title) + ->execute(); + + if (empty($menu_link_ids)) { + return NULL; } + $menu_link_id = reset($menu_link_ids); + return MenuLinkContent::load($menu_link_id); } diff --git a/src/ParagraphsTrait.php b/src/ParagraphsTrait.php index 3323c72..bb8ad6c 100644 --- a/src/ParagraphsTrait.php +++ b/src/ParagraphsTrait.php @@ -8,6 +8,7 @@ use Behat\Gherkin\Node\TableNode; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\paragraphs\Entity\Paragraph; +use Drupal\paragraphs\ParagraphInterface; /** * Trait ParagraphsTrait. @@ -23,9 +24,9 @@ trait ParagraphsTrait { /** * Array of created paragraph entities. * - * @var array + * @var array */ - protected static $paragraphs = []; + protected static $paragraphEntities = []; /** * Clean all paragraphs instances after scenario run. @@ -38,16 +39,10 @@ public function paragraphsCleanAll(AfterScenarioScope $scope): void { return; } - foreach (static::$paragraphs as $paragraph) { - try { - $paragraph->delete(); - } - catch (\Exception) { - // Ignore the exception and move on. - continue; - } + foreach (static::$paragraphEntities as $paragraph) { + $paragraph->delete(); } - static::$paragraphs = []; + static::$paragraphEntities = []; } /** @@ -66,12 +61,11 @@ public function paragraphsAddToEntityWithFields(string $field_name, string $bund // Find previously created entity by entity_type, bundle and identifying // field value. - $entity = $this->paragraphsFindEntity([ - 'field_value' => $entity_field_identifer, - 'field_name' => $entity_field_name, - 'bundle' => $bundle, - 'entity_type' => $entity_type, - ]); + $parent_entity = $this->paragraphsFindEntity($entity_type, $bundle, $entity_field_name, $entity_field_identifer); + + if (!$parent_entity) { + throw new \RuntimeException(sprintf('Parent entity "%s" with field "%s" of value "%s" not found', $bundle, $entity_field_name, $entity_field_identifer)); + } // Get fields from scenario, parse them and expand values according to // field tables. @@ -80,16 +74,15 @@ public function paragraphsAddToEntityWithFields(string $field_name, string $bund $this->parseEntityFields('paragraph', $stub); $this->paragraphsExpandEntityFields('paragraph', $stub); - // Attach paragraph from stub to node. - $this->paragraphsAttachFromStubToEntity($entity, $field_name, $paragraph_type, $stub); + $this->paragraphsAttachFromStubToEntity($parent_entity, $field_name, $paragraph_type, $stub); } /** * Create a paragraphs item from a stub and attach it to an entity. * - * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * @param \Drupal\Core\Entity\ContentEntityInterface $parent_entity * Node to attach paragraph to. - * @param string $entity_field_name + * @param string $parent_entity_field_name * Field name on the entity that refers paragraphs item. * @param string $paragraph_bundle * Paragraphs item bundle name. @@ -99,60 +92,73 @@ public function paragraphsAddToEntityWithFields(string $field_name, string $bund * @param bool $save_entity * Flag to save node after attaching a paragraphs item. Defaults to TRUE. * - * @return \Drupal\paragraphs\Entity\Paragraph + * @return \Drupal\paragraphs\ParagraphInterface * Created paragraphs item. */ - protected function paragraphsAttachFromStubToEntity(ContentEntityInterface $entity, string $entity_field_name, string $paragraph_bundle, \StdClass $stub, bool $save_entity = TRUE): Paragraph { + protected function paragraphsAttachFromStubToEntity(ContentEntityInterface $parent_entity, string $parent_entity_field_name, string $paragraph_bundle, \StdClass $stub, bool $save_entity = TRUE): ParagraphInterface { $stub->type = $paragraph_bundle; $stub = (array) $stub; + $paragraph = Paragraph::create($stub); - $paragraph->setParentEntity($entity, $entity_field_name)->save(); - $existing_value = $entity->get($entity_field_name); - $new_value = $existing_value->getValue(); + $paragraph->setParentEntity($parent_entity, $parent_entity_field_name)->save(); + + $new_value = $parent_entity->get($parent_entity_field_name)->getValue(); $new_value[] = [ 'target_id' => $paragraph->id(), 'target_revision_id' => $paragraph->getRevisionId(), ]; - $entity->set($entity_field_name, $new_value); + $parent_entity->set($parent_entity_field_name, $new_value); if ($save_entity) { - $entity->save(); + $parent_entity->save(); } - static::$paragraphs[] = $paragraph; + static::$paragraphEntities[] = $paragraph; return $paragraph; } /** - * Find entity using provided conditions. + * Find entity. + * + * @param string $entity_type + * Entity type. + * @param string $bundle + * Bundle name. + * @param string $field_name + * Field name. + * @param string $field_value + * Field value. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * Found entity or NULL if not found. */ - protected function paragraphsFindEntity(array $conditions = []): ContentEntityInterface|null { - $type = ($conditions['entity_type'] === 'taxonomy_term') ? 'vid' : 'type'; - $query = \Drupal::entityQuery($conditions['entity_type']) + protected function paragraphsFindEntity(string $entity_type, string $bundle, string $field_name, string $field_value): ?ContentEntityInterface { + $query = \Drupal::entityQuery($entity_type) ->accessCheck(FALSE) - ->condition($type, $conditions['bundle']) - ->condition($conditions['field_name'], $conditions['field_value']); + ->condition($entity_type === 'taxonomy_term' ? 'vid' : 'type', $bundle) + ->condition($field_name, $field_value); $entity_ids = $query->execute(); if (empty($entity_ids)) { - throw new \Exception(sprintf('Unable to find entity that matches conditions: "%s"', print_r($conditions, TRUE))); + return NULL; } $entity_id = array_pop($entity_ids); - $entity = \Drupal::entityTypeManager()->getStorage($conditions['entity_type'])->load($entity_id); + $entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load($entity_id); - if (!$entity instanceof ContentEntityInterface) { - throw new \Exception(sprintf('Unable to load entity "%s" with id "%s"', $conditions['entity_type'], $entity_id)); - } - - return $entity; + return $entity instanceof ContentEntityInterface ? $entity : NULL; } /** * Expand parsed fields into expected field values based on field type. + * + * @param string $entity_type + * Entity type. + * @param \StdClass $stub + * Stub object. */ protected function paragraphsExpandEntityFields(string $entity_type, \StdClass $stub): void { $core = $this->getDriver()->getCore(); @@ -165,7 +171,17 @@ protected function paragraphsExpandEntityFields(string $entity_type, \StdClass $ } /** - * Get a field name that references the paragraphs item. + * Validate that an entity has a field. + * + * @param string $entity_type + * Entity type. + * @param string $bundle + * Bundle name. + * @param string $field_name + * Field name. + * + * @throws \RuntimeException + * If the field does not exist on the entity. */ protected function paragraphsValidateEntityFieldName(string $entity_type, string $bundle, string $field_name): void { /** @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_info */ diff --git a/src/PathTrait.php b/src/PathTrait.php index d6cb1bf..27418c1 100644 --- a/src/PathTrait.php +++ b/src/PathTrait.php @@ -27,8 +27,17 @@ trait PathTrait { */ public function pathAssertCurrent(string $path): void { $current_path = $this->getSession()->getCurrentUrl(); + if (empty($current_path)) { + throw new \Exception('Current path is empty'); + } + $current_path = parse_url((string) $current_path, PHP_URL_PATH); - $current_path = ltrim($current_path, '/'); + + if ($current_path === FALSE) { + throw new \Exception('Current path is not a valid URL'); + } + + $current_path = ltrim((string) $current_path, '/'); $current_path = $current_path === '' ? '' : $current_path; if ($current_path !== ltrim($path, '/')) { @@ -50,8 +59,17 @@ public function pathAssertCurrent(string $path): void { */ public function pathAssertNotCurrent(string $path): bool { $current_path = $this->getSession()->getCurrentUrl(); + if (empty($current_path)) { + throw new \Exception('Current path is empty'); + } + $current_path = parse_url((string) $current_path, PHP_URL_PATH); - $current_path = ltrim($current_path, '/'); + + if ($current_path === FALSE) { + throw new \Exception('Current path is not a valid URL'); + } + + $current_path = ltrim((string) $current_path, '/'); $current_path = $current_path === '' ? '' : $current_path; if ($current_path === $path) { diff --git a/src/RoleTrait.php b/src/RoleTrait.php index 5f5e7d3..15dba2e 100644 --- a/src/RoleTrait.php +++ b/src/RoleTrait.php @@ -19,15 +19,17 @@ trait RoleTrait { /** * Roles ids. + * + * @var array */ - protected array $rolesNeedClean = []; + protected array $rolesIds = []; /** * Create a single role with specified permissions. * * @Given role :name with permissions :permissions */ - public function roleCreateSingle(string $name, string $permissions) { + public function roleCreateSingle(string $name, string $permissions): void { $permissions = array_map(trim(...), explode(',', $permissions)); $rid = strtolower($name); @@ -38,22 +40,19 @@ public function roleCreateSingle(string $name, string $permissions) { $existing_role->delete(); } + /** @var \Drupal\user\RoleInterface $role */ $role = \Drupal::entityTypeManager()->getStorage('user_role')->create([ 'id' => $rid, 'label' => $name, ]); $saved = $role->save(); - if ($saved === SAVED_NEW) { - user_role_grant_permissions($role->id(), $permissions); - // Mark for clean later. - $this->rolesNeedClean[$role->id()] = $role->id(); - - return Role::load($role->id()); - + if ($saved !== SAVED_NEW) { + throw new \RuntimeException(sprintf('Failed to create a role with "%s" permission(s).', implode(', ', $permissions))); } + $this->rolesIds[(string) $role->id()] = (string) $role->id(); - throw new \RuntimeException(sprintf('Failed to create a role with "%s" permission(s).', implode(', ', $permissions))); + user_role_grant_permissions($role->id(), $permissions); } /** @@ -83,14 +82,14 @@ public function roleCleanAll(AfterScenarioScope $scope): void { return; } - foreach ($this->rolesNeedClean as $rid) { + foreach ($this->rolesIds as $rid) { $role = Role::load($rid); if ($role) { $role->delete(); } } - $this->rolesNeedClean = []; + $this->rolesIds = []; } } diff --git a/src/SearchApiTrait.php b/src/SearchApiTrait.php index 2de1e20..442f822 100644 --- a/src/SearchApiTrait.php +++ b/src/SearchApiTrait.php @@ -25,7 +25,7 @@ trait SearchApiTrait { * @When I index :type :title for search */ public function searchApiIndexContent(string $type, string $title): void { - $nids = $this->contentNodeLoadMultiple($type, [ + $nids = $this->contentLoadMultiple($type, [ 'title' => $title, ]); diff --git a/src/TaxonomyTrait.php b/src/TaxonomyTrait.php index c34e464..15ac201 100644 --- a/src/TaxonomyTrait.php +++ b/src/TaxonomyTrait.php @@ -82,6 +82,7 @@ public function taxonomyDeleteTerms(string $vocabulary, TableNode $termsTable): 'name' => $name, 'vid' => $vocabulary, ]); + /** @var \Drupal\taxonomy\Entity\Term $term */ foreach ($terms as $term) { $term->delete(); @@ -104,10 +105,11 @@ public function taxonomyVisitTermPageWithName(string $vocabulary, string $name): } ksort($tids); - $tid = end($tids); + $path = $this->locatePath('/taxonomy/term/' . $tid); print $path; + $this->getSession()->visit($path); } @@ -126,10 +128,11 @@ public function taxonomyEditTermPageWithName(string $vocabulary, string $name): } ksort($tids); - $tid = end($tids); + $path = $this->locatePath('/taxonomy/term/' . $tid . '/edit'); print $path; + $this->getSession()->visit($path); } @@ -138,13 +141,13 @@ public function taxonomyEditTermPageWithName(string $vocabulary, string $name): * * @param string $vocabulary * The term vocabulary. - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of term ids. */ - protected function taxonomyLoadMultiple(string $vocabulary, array $conditions = []): int|array { + protected function taxonomyLoadMultiple(string $vocabulary, array $conditions = []): array { $query = \Drupal::entityQuery('taxonomy_term') ->accessCheck(FALSE) ->condition('vid', $vocabulary); diff --git a/src/UserTrait.php b/src/UserTrait.php index b8c3864..0e2c749 100644 --- a/src/UserTrait.php +++ b/src/UserTrait.php @@ -6,7 +6,6 @@ use Behat\Gherkin\Node\TableNode; use Drupal\user\Entity\User; -use Drupal\user\UserInterface; /** * Trait UserTrait. @@ -25,7 +24,14 @@ trait UserTrait { * @When I visit user :name profile */ public function userVisitProfile(string $name): void { - $user = $this->userGetByName($name); + $users = $this->userLoadMultiple(['name' => $name]); + + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); + } + + $user = reset($users); + $this->visitPath('/user/' . $user->id()); } @@ -53,7 +59,14 @@ public function userVisitOwnProfile(): void { * @When I edit user :name profile */ public function userEditProfile(string $name): void { - $user = $this->userGetByName($name); + $users = $this->userLoadMultiple(['name' => $name]); + + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); + } + + $user = reset($users); + $this->visitPath('/user/' . $user->id() . '/edit'); } @@ -64,20 +77,17 @@ public function userEditProfile(string $name): void { */ public function userDelete(TableNode $usersTable): void { foreach ($usersTable->getHash() as $userHash) { - $user = NULL; - try { - if (isset($userHash['mail'])) { - $user = $this->userGetByMail($userHash['mail']); - } - elseif (isset($userHash['name'])) { - $user = $this->userGetByName($userHash['name']); - } + $users = []; + + if (isset($userHash['mail'])) { + $users = $this->userLoadMultiple(['mail' => $userHash['mail']]); } - catch (\Exception) { - // User may not exist - do nothing. + elseif (isset($userHash['name'])) { + $users = $this->userLoadMultiple(['name' => $userHash['name']]); } - if ($user) { + if (!empty($users)) { + $user = reset($users); $user->delete(); $this->getUserManager()->removeUser($user->getAccountName()); } @@ -90,7 +100,13 @@ public function userDelete(TableNode $usersTable): void { * @Then user :name has :roles role(s) assigned */ public function userAssertHasRoles(string $name, string $roles): void { - $user = $this->userGetByName($name); + $users = $this->userLoadMultiple(['name' => $name]); + + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); + } + + $user = reset($users); $roles = explode(',', $roles); $roles = array_map(function ($value): string { @@ -108,7 +124,13 @@ public function userAssertHasRoles(string $name, string $roles): void { * @Then user :name does not have :roles role(s) assigned */ public function userAssertHasNoRoles(string $name, string $roles): void { - $user = $this->userGetByName($name); + $users = $this->userLoadMultiple(['name' => $name]); + + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); + } + + $user = reset($users); $roles = explode(',', $roles); $roles = array_map(function ($value): string { @@ -126,12 +148,25 @@ public function userAssertHasNoRoles(string $name, string $roles): void { * @Then user :name has :status status */ public function userAssertHasStatus(string $name, string $status): void { - $status = $status === 'active'; + if (!in_array($status, ['active', 'blocked'])) { + throw new \Exception(sprintf('Invalid status "%s".', $status)); + } + + $users = $this->userLoadMultiple(['name' => $name]); - $user = $this->userGetByName($name); + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); + } + + $user = reset($users); - if ($user->isActive() != $status) { - throw new \Exception(sprintf('User "%s" is expected to have status "%s", but has status "%s".', $name, $status ? 'active' : 'blocked', $user->isActive() ? 'active' : 'blocked')); + if ($status === 'active') { + if (!$user->isActive()) { + throw new \Exception(sprintf('User "%s" is expected to have status "active", but has status "blocked".', $name)); + } + } + elseif ($user->isActive()) { + throw new \Exception(sprintf('User "%s" is expected to have status "blocked", but has status "active".', $name)); } } @@ -145,19 +180,17 @@ public function userSetPassword(string $name, string $password): void { throw new \RuntimeException('Password must be not empty.'); } - try { - /** @var \Drupal\user\UserInterface $user */ - $user = $this->userGetByName($name); + $users = $this->userLoadMultiple(['name' => $name]); + if (empty($users)) { + $users = $this->userLoadMultiple(['mail' => $name]); } - catch (\Exception) { - try { - $user = $this->userGetByMail($name); - } - catch (\Exception) { - throw new \Exception(sprintf('Unable to find a user with name or email "%s".', $name)); - } + + if (empty($users)) { + throw new \RuntimeException(sprintf('Unable to find a user with name or email "%s".', $name)); } + $user = reset($users); + $user->setPassword($password)->save(); } @@ -167,47 +200,25 @@ public function userSetPassword(string $name, string $password): void { * @Then the last access time of user :name is :time */ public function setUserLastAccess(string $name, string $time): void { - /** @var \Drupal\user\UserInterface $user */ - $user = $this->userGetByName($name); - $timestamp = (int) static::dateRelativeProcessValue($time, time()); - $user->setLastAccessTime($timestamp)->save(); - } - - /** - * Get user by name. - */ - protected function userGetByName(string $name): UserInterface { $users = $this->userLoadMultiple(['name' => $name]); - $user = reset($users); - if (!$user) { - throw new \RuntimeException(sprintf('Unable to find user with name "%s".', $name)); + if (empty($users)) { + throw new \RuntimeException(sprintf('User "%s" does not exist.', $name)); } - return $user; - } - - /** - * Get user by mail. - */ - protected function userGetByMail(string $mail) { - $users = $this->userLoadMultiple(['mail' => $mail]); $user = reset($users); - if (!$user) { - throw new \RuntimeException(sprintf('Unable to find user with mail "%s".', $mail)); - } - - return $user; + $timestamp = (int) static::dateRelativeProcessValue($time, time()); + $user->setLastAccessTime($timestamp)->save(); } /** * Load multiple users with specified conditions. * - * @param array $conditions + * @param array $conditions * Conditions keyed by field names. * - * @return array + * @return array * Array of loaded user objects. */ protected function userLoadMultiple(array $conditions = []): array { diff --git a/src/WatchdogTrait.php b/src/WatchdogTrait.php index e183023..6135815 100644 --- a/src/WatchdogTrait.php +++ b/src/WatchdogTrait.php @@ -27,7 +27,7 @@ trait WatchdogTrait { /** * Array of watchdog message types. * - * @var array + * @var array */ protected $watchdogMessageTypes = []; @@ -54,12 +54,12 @@ public function watchdogSetScenarioStartTime(BeforeScenarioScope $scope): void { * @watchdog:my_module_type @watchdog:my_other_module_type * @endcode * - * @param array $tags + * @param array $tags * Array of scenario tags. * @param string $prefix * Optional tag prefix to filter by. * - * @return array + * @return array * Array of message types. 'php' is always added to the list. */ protected function watchdogParseMessageTypes(array $tags = [], string $prefix = 'watchdog:'): array { diff --git a/src/WysiwygTrait.php b/src/WysiwygTrait.php index 67a3d1b..02af5e0 100644 --- a/src/WysiwygTrait.php +++ b/src/WysiwygTrait.php @@ -83,7 +83,7 @@ public function wysiwygFillField(string $field, string $value): void { /** * Returns fixed step argument (with \\" replaced back to "). */ - protected function wysiwygFixStepArgument(string $argument): string|array { + protected function wysiwygFixStepArgument(string $argument): string { return str_replace('\\"', '"', $argument); } diff --git a/tests/behat/bootstrap/BehatCliTrait.php b/tests/behat/bootstrap/BehatCliTrait.php index 37c2e87..e371a98 100644 --- a/tests/behat/bootstrap/BehatCliTrait.php +++ b/tests/behat/bootstrap/BehatCliTrait.php @@ -7,6 +7,8 @@ * phpcs:disable Drupal.Commenting.DocComment.MissingShort */ +declare(strict_types=1); + use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\PyStringNode; use PHPUnit\Framework\Assert; @@ -21,16 +23,16 @@ trait BehatCliTrait { /** * @BeforeScenario */ - public function behatCliBeforeScenario(BeforeScenarioScope $scope) { + public function behatCliBeforeScenario(BeforeScenarioScope $scope): void { $traits = []; // Scan scenario tags and extract trait names from tags starting with // 'trait:'. For example, @trait:PathTrait or @trait:D7\\UserTrait. foreach ($scope->getScenario()->getTags() as $tag) { - if (strpos($tag, 'trait:') === 0) { + if (str_starts_with($tag, 'trait:')) { $tags = trim(substr($tag, strlen('trait:'))); $tags = explode(',', $tags); - $tags = array_map(function ($value) { + $tags = array_map(function ($value): string { return trim(str_replace('\\\\', '\\', $value)); }, $tags); $traits = array_merge($traits, $tags); @@ -52,7 +54,7 @@ public function behatCliBeforeScenario(BeforeScenarioScope $scope) { /** * @BeforeStep */ - public function behatCliBeforeStep() { + public function behatCliBeforeStep(): void { // Drupal Extension >= ^5 is coupled with Drupal core's DrupalTestBrowser. // This requires Drupal root to be discoverable when running Behat from a // random directory using Drupal Finder. @@ -74,14 +76,14 @@ public function behatCliBeforeStep() { * @return string * Path to written file. */ - public function behatCliWriteFeatureContextFile(array $traits = []) { + public function behatCliWriteFeatureContextFile(array $traits = []): string { $tokens = [ '{{USE_DECLARATION}}' => '', '{{USE_IN_CLASS}}' => '', ]; foreach ($traits as $trait) { $tokens['{{USE_DECLARATION}}'] .= sprintf('use DrevOps\\BehatSteps\\%s;' . PHP_EOL, $trait); - $trait_name__parts = explode('\\', $trait); + $trait_name__parts = explode('\\', (string) $trait); $trait_name = end($trait_name__parts); $tokens['{{USE_IN_CLASS}}'] .= sprintf('use %s;' . PHP_EOL, $trait_name); } @@ -116,7 +118,7 @@ public function setWatchdogErrorDrupal7($level, $type = 'php') { * @Given set watchdog error level :level * @Given set watchdog error level :level of type :type */ - public function setWatchdogErrorDrupal9($level, $type = 'php') { + public function testSetWatchdogError($level, $type = 'php') { \Drupal::logger($type)->log($level, 'test'); } @@ -146,7 +148,7 @@ public function setWatchdogErrorDrupal9($level, $type = 'php') { /** * @Given /^scenario steps(?: tagged with "([^"]*)")?:$/ */ - public function behatCliWriteScenarioSteps(PyStringNode $content, $tags = '') { + public function behatCliWriteScenarioSteps(PyStringNode $content, $tags = ''): void { $content = strtr((string) $content, ["'''" => '"""']); // Make sure that indentation in provided content is accurate. @@ -182,7 +184,7 @@ public function behatCliWriteScenarioSteps(PyStringNode $content, $tags = '') { /** * @Given some behat configuration */ - public function behatCliWriteBehatYml() { + public function behatCliWriteBehatYml(): void { $content = <<<'EOL' default: suites: @@ -218,7 +220,7 @@ public function behatCliWriteBehatYml() { /** * @Then it should fail with an error: */ - public function behatCliAssertFailWithError(PyStringNode $message) { + public function behatCliAssertFailWithError(PyStringNode $message): void { $this->itShouldFail('fail'); Assert::assertStringContainsString(trim((string) $message), $this->getOutput()); // Enforce \Exception for all assertion exceptions. Non-assertion @@ -230,7 +232,7 @@ public function behatCliAssertFailWithError(PyStringNode $message) { /** * @Then it should fail with an exception: */ - public function behatCliAssertFailWithException(PyStringNode $message) { + public function behatCliAssertFailWithException(PyStringNode $message): void { $this->itShouldFail('fail'); Assert::assertStringContainsString(trim((string) $message), $this->getOutput()); // Enforce \RuntimeException for all non-assertion exceptions. Assertion @@ -242,24 +244,24 @@ public function behatCliAssertFailWithException(PyStringNode $message) { /** * Helper to print file comments. */ - protected static function behatCliPrintFileContents($filename, $title = '') { + protected static function behatCliPrintFileContents(string $filename, $title = '') { if (!is_readable($filename)) { throw new \RuntimeException(sprintf('Unable to access file "%s"', $filename)); } $content = file_get_contents($filename); - print "-------------------- $title START --------------------" . PHP_EOL; + print sprintf('-------------------- %s START --------------------', $title) . PHP_EOL; print $filename . PHP_EOL; print_r($content); print PHP_EOL; - print "-------------------- $title FINISH --------------------" . PHP_EOL; + print sprintf('-------------------- %s FINISH --------------------', $title) . PHP_EOL; } /** * Helper to check if debug mode is enabled. */ - protected static function behatCliIsDebug() { + protected static function behatCliIsDebug(): bool { // Change to TRUE to see debug messages for this trait. return FALSE; } diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index d76b28b..2577c98 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -5,6 +5,8 @@ * Feature context for testing Behat-steps. */ +declare(strict_types=1); + use DrevOps\BehatSteps\BigPipeTrait; use DrevOps\BehatSteps\ContentTrait; use DrevOps\BehatSteps\CookieTrait; @@ -83,7 +85,7 @@ class FeatureContext extends DrupalContext { * This cannot be moved to FeatureContextTrait because traits cannot override * methods from other traits. */ - public static function dateNow(): int { + protected static function dateNow(): int { return strtotime('2024-07-15 12:00:00'); } diff --git a/tests/behat/bootstrap/FeatureContextTrait.php b/tests/behat/bootstrap/FeatureContextTrait.php index a522fcf..922b9b3 100644 --- a/tests/behat/bootstrap/FeatureContextTrait.php +++ b/tests/behat/bootstrap/FeatureContextTrait.php @@ -10,6 +10,8 @@ * phpcs:disable Drupal.Commenting.DocComment.MissingShort */ +declare(strict_types=1); + use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Gherkin\Node\PyStringNode; use Behat\Mink\Driver\Selenium2Driver; @@ -30,7 +32,7 @@ trait FeatureContextTrait { * * @AfterFeature @errorcleanup */ - public static function cleanWatchdog(AfterFeatureScope $scope) { + public static function testClearWatchdog(AfterFeatureScope $scope): void { $database = Database::getConnection(); if ($database->schema()->tableExists('watchdog')) { $database->truncate('watchdog')->execute(); @@ -40,7 +42,7 @@ public static function cleanWatchdog(AfterFeatureScope $scope) { /** * @Then user :name does not exist */ - public function userDoesNotExist($name) { + public function testAssertUserDoesNotExist(string $name): void { // We need to check that user was removed from both DB and test variables. $users = $this->userLoadMultiple(['name' => $name]); $user = reset($users); @@ -52,7 +54,7 @@ public function userDoesNotExist($name) { try { $this->getUserManager()->getUser($name); } - catch (\Exception $exception) { + catch (\Exception) { return; } @@ -63,47 +65,26 @@ public function userDoesNotExist($name) { * @Given set watchdog error level :level * @Given set watchdog error level :level of type :type */ - public function setWatchdogErrorDrupal9($level, $type = 'php') { + public function testSetWatchdogError(string $level, string $type = 'php'): void { \Drupal::logger($type)->log($level, 'test'); } /** * @Given cookie :name exists */ - public function assertCookieExists($name) { - $cookies = $this->getCookies(); + public function testAssertCookieExists(string $name): void { + $cookies = $this->testGetAllCookies(); if (!isset($cookies[$name])) { throw new \Exception(sprintf('Cookie "%s" does not exist.', $name)); } } - /** - * Get a list of cookies. - */ - protected function getCookies() { - $cookie_list = []; - - $driver = $this->getSession()->getDriver(); - if ($driver instanceof Selenium2Driver) { - $cookies = $driver->getWebDriverSession()->getAllCookies(); - foreach ($cookies as $cookie) { - $cookie_list[$cookie['name']] = $cookie['value']; - } - } - else { - /** @var \Behat\Mink\Driver\BrowserKitDriver $driver */ - $cookie_list = $driver->getClient()->getCookieJar()->allValues($driver->getCurrentUrl()); - } - - return $cookie_list; - } - /** * @Given cookie :name does not exist */ - public function assertCookieNotExists($name) { - $cookies = $this->getCookies(); + public function testAssertCookieNotExists(string $name): void { + $cookies = $this->testGetAllCookies(); if (isset($cookies[$name])) { throw new \Exception(sprintf('Cookie "%s" exists but should not.', $name)); @@ -113,7 +94,7 @@ public function assertCookieNotExists($name) { /** * @Given I install a :name module */ - public function installModule($name) { + public function testInstallModule(string $name): void { /** @var \Drupal\Core\Extension\ModuleHandler $module_handler */ $module_handler = \Drupal::service('module_handler'); if ($module_handler->moduleExists($name)) { @@ -126,8 +107,8 @@ public function installModule($name) { try { $result = $module_installer->install([$name]); } - catch (MissingDependencyException $exception) { - throw new \Exception(sprintf('Unable to install a module "%s": %s.', $name, $exception->getMessage())); + catch (MissingDependencyException $missingDependencyException) { + throw new \Exception(sprintf('Unable to install a module "%s": %s.', $name, $missingDependencyException->getMessage()), $missingDependencyException->getCode(), $missingDependencyException); } if (!$result) { @@ -138,7 +119,7 @@ public function installModule($name) { /** * @Given I uninstall a :name module */ - public function uninstallModule($name) { + public function testUninstallModule(string $name): void { /** @var \Drupal\Core\Extension\ModuleHandler $module_handler */ $module_handler = \Drupal::service('module_handler'); if (!$module_handler->moduleExists($name)) { @@ -159,7 +140,7 @@ public function uninstallModule($name) { * @When I send test email to :email with * @When I send test email to :email with: */ - public function sendTestEmail($email, PyStringNode $string) { + public function testSendEmail(string $email, PyStringNode $string): void { \Drupal::service('plugin.manager.mail')->mail( 'mysite_core', 'test_email', @@ -173,7 +154,7 @@ public function sendTestEmail($email, PyStringNode $string) { /** * @Then :file_name file object exists */ - public function fileObjectExist($file_name) { + public function testAssertFileObjectExists(string $file_name): void { $file_name = basename($file_name); $fids = $this->fileLoadMultiple(['filename' => $file_name]); if (empty($fids)) { @@ -191,7 +172,7 @@ public function fileObjectExist($file_name) { /** * @Then no :file_name file object exists */ - public function noFileObjectExist($file_name) { + public function testAssertFileObjectNotExists(string $file_name): void { $file_name = basename($file_name); $fids = $this->fileLoadMultiple(['filename' => $file_name]); if ($fids) { @@ -202,7 +183,7 @@ public function noFileObjectExist($file_name) { /** * @Then :entity_type entity exists with UUID :uuid */ - public function entityExistsByUuid($entity_type, $uuid) { + public function testAssertEntityExistsByUuid(string $entity_type, string $uuid): void { $entity = \Drupal::service('entity.repository')->loadEntityByUuid($entity_type, $uuid); if (!$entity) { @@ -210,6 +191,30 @@ public function entityExistsByUuid($entity_type, $uuid) { } } + /** + * Get a list of cookies. + * + * @return array + * List of cookies. + */ + protected function testGetAllCookies(): array { + $cookie_list = []; + + $driver = $this->getSession()->getDriver(); + if ($driver instanceof Selenium2Driver) { + $cookies = $driver->getWebDriverSession()->getAllCookies(); + foreach ($cookies as $cookie) { + $cookie_list[$cookie['name']] = $cookie['value']; + } + } + else { + /** @var \Behat\Mink\Driver\BrowserKitDriver $driver */ + $cookie_list = $driver->getClient()->getCookieJar()->allValues($driver->getCurrentUrl()); + } + + return $cookie_list; + } + /** * @Given I set a test cookie with name :name and value :value */ diff --git a/tests/behat/features/big_pipe.feature b/tests/behat/features/big_pipe.feature index 1ec270e..911727a 100644 --- a/tests/behat/features/big_pipe.feature +++ b/tests/behat/features/big_pipe.feature @@ -1,4 +1,4 @@ -Feature: Check that BigPipeTrait works for or D9 +Feature: Check that BigPipeTrait works @api Scenario: Assert that Big Pipe cookie is set diff --git a/tests/behat/features/content.feature b/tests/behat/features/content.feature index dfeefed..cb9a3a1 100644 --- a/tests/behat/features/content.feature +++ b/tests/behat/features/content.feature @@ -1,4 +1,4 @@ -Feature: Check that ContentTrait works for or D9 +Feature: Check that ContentTrait works @api Scenario: Assert visiting a page with title of specified content type diff --git a/tests/behat/features/eck.feature b/tests/behat/features/eck.feature index 9827655..f515cef 100644 --- a/tests/behat/features/eck.feature +++ b/tests/behat/features/eck.feature @@ -1,4 +1,4 @@ -Feature: Check that EckTrait works for or D9 +Feature: Check that EckTrait works Background: Given no test_bundle test_entity_type entities: diff --git a/tests/behat/features/email.feature b/tests/behat/features/email.feature index ae3b9dc..6516a37 100644 --- a/tests/behat/features/email.feature +++ b/tests/behat/features/email.feature @@ -1,4 +1,4 @@ -Feature: Check that email assertions work for or D9 +Feature: Check that EmailTrait works @api @email Scenario: As a developer, I want to know that test email system is automatically diff --git a/tests/behat/features/file.feature b/tests/behat/features/file.feature index 150076c..e10d70e 100644 --- a/tests/behat/features/file.feature +++ b/tests/behat/features/file.feature @@ -1,4 +1,4 @@ -Feature: Check that FileTrait works for or D9 +Feature: Check that FileTrait works @api Scenario: Assert "Given managed file:" diff --git a/tests/behat/features/file_download.feature b/tests/behat/features/file_download.feature index 87ed56b..dee27ab 100644 --- a/tests/behat/features/file_download.feature +++ b/tests/behat/features/file_download.feature @@ -1,4 +1,4 @@ -Feature: Check that FileDownloadTrait works for or D9 +Feature: Check that FileDownloadTrait works Background: Given I am logged in as a user with the "administrator" role diff --git a/tests/behat/features/media.feature b/tests/behat/features/media.feature index 8b9e23b..9f414cb 100644 --- a/tests/behat/features/media.feature +++ b/tests/behat/features/media.feature @@ -1,4 +1,4 @@ -Feature: Check that MediaTrait works for or D9 +Feature: Check that MediaTrait works @api Scenario: Assert "When I attach the file :file to :field_name media field" diff --git a/tests/behat/features/menu.feature b/tests/behat/features/menu.feature index f9853be..5b3ae45 100644 --- a/tests/behat/features/menu.feature +++ b/tests/behat/features/menu.feature @@ -1,4 +1,4 @@ -Feature: Check that MenuTrait works for or D9 +Feature: Check that MenuTrait works @api Scenario: Assert "Given menus:" diff --git a/tests/behat/features/override.feature b/tests/behat/features/override.feature index be11b89..cb3bf3f 100644 --- a/tests/behat/features/override.feature +++ b/tests/behat/features/override.feature @@ -1,4 +1,4 @@ -Feature: Check that OverrideTrait works for or D9 +Feature: Check that OverrideTrait works @api Scenario Outline: Assert override of authentication by role works diff --git a/tests/behat/features/paragraphs.feature b/tests/behat/features/paragraphs.feature index d48e612..5cdd0a9 100644 --- a/tests/behat/features/paragraphs.feature +++ b/tests/behat/features/paragraphs.feature @@ -1,4 +1,4 @@ -Feature: Check that ParagraphsTrait works for or D9 +Feature: Check that ParagraphsTrait works Background: Given I am logged in as a user with the "administrator" role diff --git a/tests/behat/features/role.feature b/tests/behat/features/role.feature index 2bc403b..b02127e 100644 --- a/tests/behat/features/role.feature +++ b/tests/behat/features/role.feature @@ -1,4 +1,4 @@ -Feature: Check that RoleTrait works for or D9 +Feature: Check that RoleTrait works @api Scenario: Assert "Given role :name with permissions :permissions" diff --git a/tests/behat/features/taxonomy.feature b/tests/behat/features/taxonomy.feature index bd81147..bda6b64 100644 --- a/tests/behat/features/taxonomy.feature +++ b/tests/behat/features/taxonomy.feature @@ -1,4 +1,4 @@ -Feature: Check that TaxonomyTrait works for or D9 +Feature: Check that TaxonomyTrait works @api Scenario: Assert "Given vocabulary :vid with name :name exists" diff --git a/tests/behat/features/user.feature b/tests/behat/features/user.feature index c2d7bd4..14638b2 100644 --- a/tests/behat/features/user.feature +++ b/tests/behat/features/user.feature @@ -1,4 +1,4 @@ -Feature: Check that UserTrait works for or D9 +Feature: Check that UserTrait works Background: Given users: @@ -64,9 +64,7 @@ Feature: Check that UserTrait works for or D9 @api Scenario: Assert "Then user :name has :status status" Given user "authenticated_user" has "active" status - - Given user "authenticated_user_disabled" has "not active" status - And user "authenticated_user_disabled" has "disabled" status + And user "authenticated_user_disabled" has "blocked" status @api Scenario: Assert "I set user :user password to :password" @@ -81,7 +79,7 @@ Feature: Check that UserTrait works for or D9 Given I set user "non_existing_user" password to "password123" """ When I run "behat --no-colors" - Then it should fail with an error: + Then it should fail with an exception: """ Unable to find a user with name or email "non_existing_user". """ diff --git a/tests/behat/features/watchdog.feature b/tests/behat/features/watchdog.feature index 48a2aec..dcde974 100644 --- a/tests/behat/features/watchdog.feature +++ b/tests/behat/features/watchdog.feature @@ -1,5 +1,5 @@ @errorcleanup -Feature: Check that WatchdogTrait works for or D9 +Feature: Check that WatchdogTrait works @trait:WatchdogTrait Scenario: Assert that watchdog fails with an error