diff --git a/composer.json b/composer.json index 35bdcb166cbf5836a94e70f3572c65ed3c055cd4..22c0dfede2cd1b308b8bc1955126974aa2550c5e 100755 --- a/composer.json +++ b/composer.json @@ -39,8 +39,8 @@ }, "patches": { "drupal/core": { - "Issue #3397897: Add Drupal Recipes patch for Drupal core": - "web/modules/contrib/webpatches/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch", + "Issue #3410330: Switch the local patch to have Recipes functionality applied to Drupal core from the Distributions and Recipes project": + "https://git.drupalcode.org/project/distributions_recipes/-/raw/patch/recipe-10.2.x.patch", "Issue #3272720: Fix PHP8.1+ Deprecated function: hash(): Passing null to parameter #2 ($data) of type string is deprecated in generateFieldTableName": "web/modules/contrib/webpatches/patches/core-drupal--10-2-x--2022-07-21--3272720-6.patch", "Issue #3326684: Fix PHP8.1+ Deprecated function: mb_strtolower(): Passing null to parameter #1 ($string) of type string is deprecated": diff --git a/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch b/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch deleted file mode 100644 index fa97d8a5f613997b9118aee25915c8a2c5cdd060..0000000000000000000000000000000000000000 --- a/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch +++ /dev/null @@ -1,3983 +0,0 @@ -diff --git a/core/core.services.yml b/core/core.services.yml -index e9d75306..581f1dff 100644 ---- a/core/core.services.yml -+++ b/core/core.services.yml -@@ -57,6 +57,10 @@ parameters: - supportsCredentials: false - tempstore.expire: 604800 - services: -+ plugin.manager.config_action: -+ class: Drupal\Core\Config\Action\ConfigActionManager -+ parent: default_plugin_manager -+ arguments: ['@config.manager'] - # Simple cache contexts, directly derived from the request context. - cache_context.ip: - class: Drupal\Core\Cache\Context\IpCacheContext -diff --git a/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php b/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php -new file mode 100644 -index 00000000..810ac205 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php -@@ -0,0 +1,47 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Annotation; -+ -+use Drupal\Component\Annotation\Plugin; -+use Drupal\Core\Annotation\Translation; -+use Drupal\Core\StringTranslation\TranslatableMarkup; -+ -+/** -+ * Defines a ConfigAction annotation object. -+ * -+ * @ingroup config_action_api -+ * -+ * @Annotation -+ */ -+class ConfigAction extends Plugin { -+ -+ /** -+ * The plugin ID. -+ * -+ * @var string -+ */ -+ public string $id; -+ -+ /** -+ * The administrative label of the config action. -+ * -+ * @var \Drupal\Core\Annotation\Translation|\Drupal\Core\StringTranslation\TranslatableMarkup|string -+ * -+ * @ingroup plugin_translatable -+ */ -+ public Translation|TranslatableMarkup|string $admin_label = ''; -+ -+ /** -+ * Allows action shorthand IDs for the listed config entity types. -+ * -+ * If '*' is present in the array then it can apply to all entity types. An -+ * empty array means that shorthand action IDs are not available for this -+ * plugin. -+ * -+ * @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId() -+ * -+ * @var string[] -+ */ -+ public array $entity_types = []; -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php -new file mode 100644 -index 00000000..0d2f481e ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php -@@ -0,0 +1,39 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Attribute; -+ -+// cspell:ignore inflector -+use Drupal\Core\Config\Action\Exists; -+use Drupal\Core\StringTranslation\TranslatableMarkup; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+#[\Attribute(\Attribute::TARGET_METHOD)] -+final class ActionMethod { -+ -+ /** -+ * @param \Drupal\Core\Config\Action\Exists $exists -+ * Determines behavior of action depending on entity existence. -+ * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel -+ * The admin label for the user interface. -+ * @param bool|string $pluralize -+ * Determines whether to create a pluralized version of the method to enable -+ * the action to be called multiple times before saving the entity. The -+ * default behaviour is to create an action with a plural form as determined -+ * by \Symfony\Component\String\Inflector\EnglishInflector::pluralize(). -+ * For example, 'grantPermission' has a pluralized version of -+ * 'grantPermissions'. If a string is provided this will be the full action -+ * ID. For example, if the method is called 'addArray' this can be set to -+ * 'addMultipleArrays'. Set to FALSE if a pluralized version does not make -+ * logical sense. -+ */ -+ public function __construct( -+ public readonly Exists $exists = Exists::ERROR_IF_NOT_EXISTS, -+ public readonly TranslatableMarkup|string $adminLabel = '', -+ public readonly bool|string $pluralize = TRUE -+ ) { -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionException.php b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php -new file mode 100644 -index 00000000..02e5220e ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php -@@ -0,0 +1,10 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class ConfigActionException extends \RuntimeException { -+} -diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php -new file mode 100644 -index 00000000..646a0141 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php -@@ -0,0 +1,123 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action; -+ -+use Drupal\Component\Plugin\PluginBase; -+use Drupal\Core\Cache\CacheBackendInterface; -+use Drupal\Core\Config\ConfigManagerInterface; -+use Drupal\Core\Extension\ModuleHandlerInterface; -+use Drupal\Core\Plugin\DefaultPluginManager; -+ -+/** -+ * @defgroup config_action_api Config Action API -+ * @{ -+ * Information about the classes and interfaces that make up the Config Action -+ * API. -+ * -+ * Configuration actions are plugins that manipulate simple configuration or -+ * configuration entities. The configuration action plugin manager can apply -+ * configuration actions. For example, the API is leveraged by recipes to create -+ * roles if they do not exist already and grant permissions to those roles. -+ * -+ * To define a configuration action in a module you need to: -+ * - Define a Config Action plugin by creating a new class that implements the -+ * \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace -+ * Plugin\ConfigAction under your module namespace. For more information about -+ * creating plugins, see the @link plugin_api Plugin API topic. @endlink -+ * - Config action plugins use the annotations defined by -+ * \Drupal\Core\Config\Action\Annotation\ConfigAction. See the -+ * @link annotation Annotations topic @endlink for more information about -+ * annotations. -+ * -+ * Further information and examples: -+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives -+ * configuration actions from config entity methods which have the -+ * \Drupal\Core\Config\Action\Attribute\ActionMethod attribute. -+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to -+ * create configuration entities if they do not exist. -+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows -+ * you to update simple configuration using a config action. -+ * @} -+ */ -+class ConfigActionManager extends DefaultPluginManager { -+ -+ /** -+ * Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object. -+ * -+ * @param \Traversable $namespaces -+ * An object that implements \Traversable which contains the root paths -+ * keyed by the corresponding namespace to look for plugin implementations. -+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend -+ * Cache backend instance to use. -+ * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler -+ * The module handler to invoke the alter hook with. -+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager -+ * The config manager. -+ */ -+ public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, protected readonly ConfigManagerInterface $configManager) { -+ assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array'); -+ // Enable this namespace to be searched for plugins. -+ $namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action'; -+ -+ parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, 'Drupal\Core\Config\Action\ConfigActionPluginInterface', 'Drupal\Core\Config\Action\Annotation\ConfigAction'); -+ -+ $this->alterInfo('config_action'); -+ $this->setCacheBackend($cache_backend, 'config_action'); -+ } -+ -+ /** -+ * Applies a config action. -+ * -+ * @param string $action_id -+ * The ID of the action to apply. This can be a complete configuration -+ * action plugin ID or a shorthand action ID that is available for the -+ * entity type of the provided configuration name. -+ * @param string $configName -+ * The configuration name. -+ * @param mixed $data -+ * The data for the action. -+ * -+ * @throws \Drupal\Component\Plugin\Exception\PluginException -+ * Thrown when the config action cannot be found. -+ * @throws \Drupal\Core\Config\Action\ConfigActionException -+ * Thrown when the config action fails to apply. -+ */ -+ public function applyAction(string $action_id, string $configName, mixed $data): void { -+ if (!$this->hasDefinition($action_id)) { -+ // Get the full plugin ID from the shorthand map, if it is available. -+ $entity_type = $this->configManager->getEntityTypeIdByName($configName); -+ if ($entity_type) { -+ $action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id; -+ } -+ } -+ /** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */ -+ $action = $this->createInstance($action_id); -+ $action->apply($configName, $data); -+ } -+ -+ /** -+ * Gets a map of shorthand action IDs to plugin IDs for an entity type. -+ * -+ * @param string $entityType -+ * The entity type ID to get the map for. -+ * -+ * @return string[] -+ * An array of plugin IDs keyed by shorthand action ID for the provided -+ * entity type. -+ */ -+ protected function getShorthandActionIdsForEntityType(string $entityType): array { -+ $map = []; -+ foreach ($this->getDefinitions() as $plugin_id => $definition) { -+ if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) { -+ $regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/'; -+ $action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id; -+ if (isset($map[$action_id])) { -+ throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType)); -+ } -+ $map[$action_id] = $plugin_id; -+ } -+ } -+ return $map; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php -new file mode 100644 -index 00000000..143ce3a3 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php -@@ -0,0 +1,19 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action; -+ -+interface ConfigActionPluginInterface { -+ -+ /** -+ * Applies the config action. -+ * -+ * @param string $configName -+ * The name of the config to apply the action to. -+ * @param mixed $value -+ * The value for the action to use. -+ * -+ * @throws ConfigActionException -+ */ -+ public function apply(string $configName, mixed $value): void; -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php -new file mode 100644 -index 00000000..7f6bdd3f ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php -@@ -0,0 +1,9 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action; -+ -+/** -+ * Exception thrown if there are conflicting shorthand action IDs. -+ */ -+class DuplicateConfigActionIdException extends \RuntimeException { -+} -diff --git a/core/lib/Drupal/Core/Config/Action/EntityMethodException.php b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php -new file mode 100644 -index 00000000..3a7b5644 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php -@@ -0,0 +1,10 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class EntityMethodException extends \RuntimeException { -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Exists.php b/core/lib/Drupal/Core/Config/Action/Exists.php -new file mode 100644 -index 00000000..ec34b574 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Exists.php -@@ -0,0 +1,42 @@ -+<?php -+// phpcs:ignoreFile -+ -+namespace Drupal\Core\Config\Action; -+ -+use Drupal\Core\Config\Entity\ConfigEntityInterface; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+enum Exists { -+ case ERROR_IF_EXISTS; -+ case ERROR_IF_NOT_EXISTS; -+ case RETURN_EARLY_IF_EXISTS; -+ case RETURN_EARLY_IF_NOT_EXISTS; -+ -+ /** -+ * Determines if an action should return early depending on $entity. -+ * -+ * @param string $configName -+ * The config name supplied to the action. -+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity -+ * The entity, if it exists. -+ * -+ * @return bool -+ * TRUE if the action should return early, FALSE if not. -+ * -+ * @throws \Drupal\Core\Config\Action\ConfigActionException -+ * Thrown depending on $entity and the value of $this. -+ */ -+ public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool { -+ return match (TRUE) { -+ $this === self::RETURN_EARLY_IF_EXISTS && $entity !== NULL, -+ $this === self::RETURN_EARLY_IF_NOT_EXISTS && $entity === NULL => TRUE, -+ $this === self::ERROR_IF_EXISTS && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)), -+ $this === self::ERROR_IF_NOT_EXISTS && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)), -+ default => FALSE -+ }; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php -new file mode 100644 -index 00000000..8f42f7ad ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php -@@ -0,0 +1,32 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver; -+ -+use Drupal\Component\Plugin\Derivative\DeriverBase; -+use Drupal\Core\Config\Action\Exists; -+use Drupal\Core\StringTranslation\StringTranslationTrait; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+class EntityCreateDeriver extends DeriverBase { -+ use StringTranslationTrait; -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getDerivativeDefinitions($base_plugin_definition) { -+ // These derivatives apply to all entity types. -+ $base_plugin_definition['entity_types'] = ['*']; -+ -+ $this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::RETURN_EARLY_IF_EXISTS]]; -+ $this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists'); -+ -+ $this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ERROR_IF_EXISTS]]; -+ $this->derivatives['create']['admin_label'] = $this->t('Entity create'); -+ -+ return $this->derivatives; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php -new file mode 100644 -index 00000000..fe99550c ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php -@@ -0,0 +1,141 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver; -+ -+// cspell:ignore inflector -+use Drupal\Component\Plugin\Derivative\DeriverBase; -+use Drupal\Component\Plugin\PluginBase; -+use Drupal\Core\Config\Action\Attribute\ActionMethod; -+use Drupal\Core\Config\Action\EntityMethodException; -+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface; -+use Drupal\Core\Entity\EntityTypeManagerInterface; -+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; -+use Drupal\Core\StringTranslation\StringTranslationTrait; -+use Symfony\Component\DependencyInjection\ContainerInterface; -+use Symfony\Component\String\Inflector\EnglishInflector; -+use Symfony\Component\String\Inflector\InflectorInterface; -+ -+/** -+ * Derives config action methods from attributed config entity methods. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface { -+ -+ use StringTranslationTrait; -+ -+ /** -+ * Inflector to pluralize words. -+ */ -+ protected readonly InflectorInterface $inflector; -+ -+ /** -+ * Constructs new EntityMethodDeriver. -+ * -+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager -+ * The entity type manager. -+ */ -+ public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) { -+ $this->inflector = new EnglishInflector(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public static function create(ContainerInterface $container, $base_plugin_id) { -+ return new static( -+ $container->get('entity_type.manager') -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getDerivativeDefinitions($base_plugin_definition) { -+ // Scan all the config entity classes for attributes. -+ foreach ($this->entityTypeManager->getDefinitions() as $entity_type) { -+ if ($entity_type instanceof ConfigEntityTypeInterface) { -+ $reflectionClass = new \ReflectionClass($entity_type->getClass()); -+ while ($reflectionClass) { -+ foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { -+ // Only process a method if it is declared on the current class. -+ // Methods on the parent class will be processed later. This allows -+ // for a parent to have an attribute and an overriding class does -+ // not need one. For example, -+ // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent() -+ // and \Drupal\Core\Entity\EntityDisplayBase::setComponent(). -+ if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) { -+ foreach ($method->getAttributes(ActionMethod::class) as $attribute) { -+ $this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition); -+ } -+ } -+ } -+ $reflectionClass = $reflectionClass->getParentClass(); -+ } -+ } -+ } -+ return $this->derivatives; -+ } -+ -+ /** -+ * Processes a method to create derivatives. -+ * -+ * @param \ReflectionMethod $method -+ * The entity method. -+ * @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute -+ * The entity method attribute. -+ * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type -+ * The entity type. -+ * @param array $derivative -+ * The base plugin definition that will used to create the derivative. -+ */ -+ private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void { -+ $derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]); -+ $derivative['constructor_args'] = [ -+ 'method' => $method->name, -+ 'exists' => $action_attribute->exists, -+ 'numberOfParams' => $method->getNumberOfParameters(), -+ 'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(), -+ 'pluralized' => FALSE, -+ ]; -+ $derivative['entity_types'] = [$entity_type->id()]; -+ // Build a config action identifier from the entity type's config -+ // prefix and the method name. For example, the Role entity adds a -+ // 'user.role:grantPermission' action. -+ $this->addDerivative($method->name, $entity_type, $derivative, $method->name); -+ -+ $pluralized_name = match(TRUE) { -+ is_string($action_attribute->pluralize) => $action_attribute->pluralize, -+ $action_attribute->pluralize === FALSE => '', -+ default => $this->inflector->pluralize($method->name)[0] -+ }; -+ // Add a pluralized version of the plugin. -+ if (strlen($pluralized_name) > 0) { -+ $derivative['constructor_args']['pluralized'] = TRUE; -+ $derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]); -+ $this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name); -+ } -+ } -+ -+ /** -+ * Adds a derivative. -+ * -+ * @param string $action_id -+ * The action ID. -+ * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type -+ * The entity type. -+ * @param array $derivative -+ * The derivative definition. -+ * @param string $methodName -+ * The method name. -+ */ -+ private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void { -+ $id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id; -+ if (isset($this->derivatives[$id])) { -+ throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method'])); -+ } -+ $this->derivatives[$id] = $derivative; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php -new file mode 100644 -index 00000000..f86f64fb ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php -@@ -0,0 +1,74 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Plugin\ConfigAction; -+ -+use Drupal\Core\Config\Action\ConfigActionException; -+use Drupal\Core\Config\Action\ConfigActionPluginInterface; -+use Drupal\Core\Config\Action\Exists; -+use Drupal\Core\Config\ConfigManagerInterface; -+use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -+use Symfony\Component\DependencyInjection\ContainerInterface; -+ -+/** -+ * @ConfigAction( -+ * id = "entity_create", -+ * deriver = "\Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver", -+ * ) -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { -+ -+ /** -+ * Constructs a EntityCreate object. -+ * -+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager -+ * The config manager. -+ * @param \Drupal\Core\Config\Action\Exists $exists -+ * Determines behavior of action depending on entity existence. -+ */ -+ public function __construct( -+ protected readonly ConfigManagerInterface $configManager, -+ protected readonly Exists $exists -+ ) { -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { -+ assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings'); -+ return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function apply(string $configName, mixed $value): void { -+ if (!is_array($value)) { -+ throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName)); -+ } -+ -+ /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */ -+ $entity = $this->configManager->loadConfigEntityByName($configName); -+ if ($this->exists->returnEarly($configName, $entity)) { -+ return; -+ } -+ -+ $entity_type_manager = $this->configManager->getEntityTypeManager(); -+ $entity_type_id = $this->configManager->getEntityTypeIdByName($configName); -+ if ($entity_type_id === NULL) { -+ throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName)); -+ } -+ /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ -+ $entity_type = $entity_type_manager->getDefinition($entity_type_id); -+ -+ $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1); -+ $entity_type_manager -+ ->getStorage($entity_type->id()) -+ ->create($value + ['id' => $id]) -+ ->save(); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php -new file mode 100644 -index 00000000..a04bd637 ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php -@@ -0,0 +1,146 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Plugin\ConfigAction; -+ -+use Drupal\Core\Config\Action\ConfigActionPluginInterface; -+use Drupal\Core\Config\Action\EntityMethodException; -+use Drupal\Core\Config\Action\Exists; -+use Drupal\Core\Config\ConfigManagerInterface; -+use Drupal\Core\Config\Entity\ConfigEntityInterface; -+use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -+use Symfony\Component\DependencyInjection\ContainerInterface; -+ -+/** -+ * Makes config entity methods with the ActionMethod attribute into actions. -+ * -+ * For example, adding the ActionMethod attribute to -+ * \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to -+ * roles via config actions. -+ * -+ * When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction() -+ * the $data parameter is mapped to the method's arguments using the following -+ * rules: -+ * - If $data is not an array, the method must only have one argument or one -+ * required argument. -+ * - If $data is an array and the method only accepts a single argument, the -+ * array will be passed to the first argument. -+ * - If $data is an array and the method accepts more than one argument, $data -+ * will be unpacked into the method arguments. -+ * -+ * @ConfigAction( -+ * id = "entity_method", -+ * deriver = "\Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver", -+ * ) -+ * -+ * @internal -+ * This API is experimental. -+ * -+ * @see \Drupal\Core\Config\Action\Attribute\ActionMethod -+ */ -+final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { -+ -+ /** -+ * Constructs a EntityMethod object. -+ * -+ * @param string $pluginId -+ * The config action plugin ID. -+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager -+ * The config manager. -+ * @param string $method -+ * The method to call on the config entity. -+ * @param \Drupal\Core\Config\Action\Exists $exists -+ * Determines behavior of action depending on entity existence. -+ * @param int $numberOfParams -+ * The number of parameters the method has. -+ * @param int $numberOfRequiredParams -+ * The number of required parameters the method has. -+ * @param bool $pluralized -+ * Determines whether an array maps to multiple calls. -+ */ -+ public function __construct( -+ protected readonly string $pluginId, -+ protected readonly ConfigManagerInterface $configManager, -+ protected readonly string $method, -+ protected readonly Exists $exists, -+ protected readonly int $numberOfParams, -+ protected readonly int $numberOfRequiredParams, -+ protected readonly bool $pluralized -+ ) { -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { -+ assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings'); -+ return new static( -+ $plugin_id, -+ $container->get('config.manager'), -+ ...$plugin_definition['constructor_args'] -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function apply(string $configName, mixed $value): void { -+ /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */ -+ $entity = $this->configManager->loadConfigEntityByName($configName); -+ if ($this->exists->returnEarly($configName, $entity)) { -+ return; -+ } -+ -+ $entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value); -+ $entity->save(); -+ } -+ -+ /** -+ * Apply the action to entity treating the $values array as multiple calls. -+ * -+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity -+ * The entity to apply the action to. -+ * @param mixed $values -+ * The values for the action to use. -+ * -+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface -+ * The unsaved entity with the action applied. -+ */ -+ private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface { -+ if (!is_array($values)) { -+ throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method)); -+ } -+ foreach ($values as $value) { -+ $entity = $this->applySingle($entity, $value); -+ } -+ return $entity; -+ } -+ -+ /** -+ * Apply the action to entity treating the $values array a single call. -+ * -+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity -+ * The entity to apply the action to. -+ * @param mixed $value -+ * The value for the action to use. -+ * -+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface -+ * The unsaved entity with the action applied. -+ */ -+ private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface { -+ // If $value is not an array then we only support calling the method if the -+ // number of parameters or required parameters is 1. If there is only 1 -+ // parameter and $value is an array then assume that the parameter expects -+ // an array. -+ if (!is_array($value) || $this->numberOfParams === 1) { -+ if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) { -+ throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method)); -+ } -+ $entity->{$this->method}($value); -+ } -+ else { -+ $entity->{$this->method}(...$value); -+ } -+ return $entity; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php -new file mode 100644 -index 00000000..5f46c65a ---- /dev/null -+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php -@@ -0,0 +1,60 @@ -+<?php -+ -+namespace Drupal\Core\Config\Action\Plugin\ConfigAction; -+ -+use Drupal\Core\Config\Action\ConfigActionException; -+use Drupal\Core\Config\Action\ConfigActionPluginInterface; -+use Drupal\Core\Config\ConfigFactoryInterface; -+use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -+use Symfony\Component\DependencyInjection\ContainerInterface; -+ -+/** -+ * @ConfigAction( -+ * id = "simple_config_update", -+ * admin_label = @Translation("Simple configuration update") -+ * ) -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { -+ -+ /** -+ * Constructs a SimpleConfigUpdate object. -+ * -+ * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory -+ * The config factory. -+ */ -+ public function __construct( -+ protected readonly ConfigFactoryInterface $configFactory, -+ ) { -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { -+ return new static($container->get('config.factory')); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function apply(string $configName, mixed $value): void { -+ $config = $this->configFactory->getEditable($configName); -+ // @todo Should we error if this is a config entity? -+ if ($config->isNew()) { -+ throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName)); -+ } -+ -+ // Expect $value to be an array whose keys are the config keys to update. -+ if (!is_array($value)) { -+ throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName)); -+ } -+ foreach ($value as $key => $value) { -+ $config->set($key, $value); -+ } -+ $config->save(); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php -index 1724adda..65079ccf 100644 ---- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php -+++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php -@@ -2,10 +2,12 @@ - - namespace Drupal\Core\Entity; - -+use Drupal\Core\Config\Action\Attribute\ActionMethod; - use Drupal\Core\Config\Entity\ConfigEntityBase; - use Drupal\Core\Config\Entity\ConfigEntityInterface; - use Drupal\Core\Field\FieldDefinitionInterface; - use Drupal\Core\Entity\Display\EntityDisplayInterface; -+use Drupal\Core\StringTranslation\TranslatableMarkup; - - /** - * Provides a common base class for entity view and form displays. -@@ -337,6 +339,7 @@ public function getComponent($name) { - /** - * {@inheritdoc} - */ -+ #[ActionMethod(adminLabel: new TranslatableMarkup('Add component to display'))] - public function setComponent($name, array $options = []) { - // If no weight specified, make sure the field sinks at the bottom. - if (!isset($options['weight'])) { -diff --git a/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php -new file mode 100644 -index 00000000..a8f3f025 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php -@@ -0,0 +1,76 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\FileStorage; -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class ConfigConfigurator { -+ -+ public readonly ?string $recipeConfigDirectory; -+ -+ /** -+ * @param array $config -+ * Config options for a recipe. -+ * @param string $recipe_directory -+ * The path to the recipe. -+ * @param \Drupal\Core\Config\StorageInterface $active_configuration -+ * The active configuration storage. -+ */ -+ public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) { -+ // @todo validate structure of $config['import'] and $config['actions']. -+ -+ $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL; -+ $recipe_storage = $this->getConfigStorage(); -+ foreach ($recipe_storage->listAll() as $config_name) { -+ if ($active_data = $active_configuration->read($config_name)) { -+ // @todo investigate if there is any generic code in core for this. -+ unset($active_data['uuid'], $active_data['_core']); -+ if (empty($active_data['dependencies'])) { -+ unset($active_data['dependencies']); -+ } -+ if ($active_data !== $recipe_storage->read($config_name)) { -+ throw new RecipePreExistingConfigException($config_name, sprintf("The configuration '%s' exists already and does not match the recipe's configuration", $config_name)); -+ } -+ } -+ } -+ } -+ -+ /** -+ * Gets a config storage object for reading config from the recipe. -+ * -+ * @return \Drupal\Core\Config\StorageInterface -+ * The config storage object for reading config from the recipe. -+ */ -+ public function getConfigStorage(): StorageInterface { -+ $storages = []; -+ -+ if ($this->recipeConfigDirectory) { -+ // Config provided by the recipe should take priority over config from -+ // extensions. -+ $storages[] = new FileStorage($this->recipeConfigDirectory); -+ } -+ if (!empty($this->config['import'])) { -+ /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */ -+ $module_list = \Drupal::service('extension.list.module'); -+ /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */ -+ $theme_list = \Drupal::service('extension.list.theme'); -+ foreach ($this->config['import'] as $extension => $config) { -+ $path = match (TRUE) { -+ $module_list->exists($extension) => $module_list->getPath($extension), -+ $theme_list->exists($extension) => $theme_list->getPath($extension), -+ default => throw new \RuntimeException("$extension is not a theme or module") -+ }; -+ $config = (array) ($config === '*' ? NULL : $config); -+ $storages[] = new RecipeExtensionConfigStorage($path, $config); -+ } -+ } -+ -+ return RecipeConfigStorageWrapper::createStorageFromArray($storages); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/ContentConfigurator.php b/core/lib/Drupal/Core/Recipe/ContentConfigurator.php -new file mode 100644 -index 00000000..753ecc16 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/ContentConfigurator.php -@@ -0,0 +1,19 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class ContentConfigurator { -+ -+ /** -+ * @param array $content -+ * Content options for a recipe. -+ */ -+ public function __construct(public readonly array $content) { -+ // @todo https://www.drupal.org/project/distributions_recipes/issues/3292287 -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/InstallConfigurator.php b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php -new file mode 100644 -index 00000000..a1207cc2 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php -@@ -0,0 +1,121 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Component\Assertion\Inspector; -+use Drupal\Core\Extension\Dependency; -+use Drupal\Core\Extension\ModuleExtensionList; -+use Drupal\Core\Extension\ThemeExtensionList; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class InstallConfigurator { -+ -+ /** -+ * The list of modules to install. -+ * -+ * This list is sorted an includes any module dependencies of the provided -+ * extensions. -+ * -+ * @var string[] -+ */ -+ public readonly array $modules; -+ -+ /** -+ * The list of themes to install. -+ * -+ * This list is sorted an includes any theme dependencies of the provided -+ * extensions. -+ * -+ * @var string[] -+ */ -+ public readonly array $themes; -+ -+ /** -+ * @param string[] $extensions -+ * A list of extensions for a recipe to install. -+ * @param \Drupal\Core\Extension\ModuleExtensionList $module_list -+ * The module list service. -+ * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list -+ * The theme list service. -+ */ -+ public function __construct(array $extensions, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) { -+ assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.'); -+ // @todo https://www.drupal.org/project/distributions_recipes/issues/3292281 -+ $extensions = array_map(fn($extension) => Dependency::createFromString($extension)->getName(), $extensions); -+ $extensions = array_combine($extensions, $extensions); -+ $module_data = $module_list->reset()->getList(); -+ $theme_data = $theme_list->reset()->getList(); -+ -+ $modules = array_intersect_key($extensions, $module_data); -+ $themes = array_intersect_key($extensions, $theme_data); -+ -+ $missing_extensions = array_diff($extensions, $modules, $themes); -+ -+ // Add theme module dependencies. -+ foreach ($themes as $theme => $value) { -+ $modules = array_merge($modules, array_keys($theme_data[$theme]->module_dependencies)); -+ } -+ -+ // Add modules that other modules depend on. -+ // @todo should recipes do this? I think so. It allows modules to add -+ // dependencies and recipes continue to work -+ foreach ($modules as $module) { -+ if ($module_data[$module]->requires) { -+ $modules = array_merge($modules, array_keys($module_data[$module]->requires)); -+ } -+ } -+ -+ // Remove all modules that have been installed already. -+ $modules = array_diff(array_unique($modules), array_keys($module_list->getAllInstalledInfo())); -+ $modules = array_combine($modules, $modules); -+ -+ // Create a sortable list of modules. -+ foreach ($modules as $name => $value) { -+ if (isset($module_data[$name])) { -+ $modules[$name] = $module_data[$name]->sort; -+ } -+ else { -+ $missing_extensions[$name] = $name; -+ } -+ } -+ -+ // Add any missing base themes to the list of themes to install. -+ foreach ($themes as $theme => $value) { -+ // $theme_data[$theme]->requires contains both theme and module -+ // dependencies keyed by the extension machine names. -+ // $theme_data[$theme]->module_dependencies contains only the module -+ // dependencies keyed by the module extension machine name. Therefore, -+ // we can find the theme dependencies by finding array keys for -+ // 'requires' that are not in $module_dependencies. -+ $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $theme_data[$theme]->module_dependencies); -+ $themes = array_merge($themes, array_keys($theme_dependencies)); -+ } -+ -+ // Remove all themes that have been installed already. -+ $themes = array_diff(array_unique($themes), array_keys($theme_list->getAllInstalledInfo())); -+ $themes = array_combine($themes, $themes); -+ -+ // Create a sortable list of themes. -+ foreach ($themes as $name => $value) { -+ if (isset($theme_data[$name])) { -+ $themes[$name] = $theme_data[$name]->sort; -+ } -+ else { -+ $missing_extensions[$name] = $name; -+ } -+ } -+ -+ if (!empty($missing_extensions)) { -+ throw new RecipeMissingExtensionsException(array_values($missing_extensions)); -+ } -+ -+ arsort($modules); -+ arsort($themes); -+ $this->modules = array_keys($modules); -+ $this->themes = array_keys($themes); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php -new file mode 100644 -index 00000000..ed35e188 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/Recipe.php -@@ -0,0 +1,70 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Serialization\Yaml; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class Recipe { -+ -+ const COMPOSER_PROJECT_TYPE = 'drupal-recipe'; -+ -+ public function __construct( -+ public readonly string $name, -+ public readonly string $description, -+ public readonly string $type, -+ public readonly RecipeConfigurator $recipes, -+ public readonly InstallConfigurator $install, -+ public readonly ConfigConfigurator $config, -+ public readonly ContentConfigurator $content -+ ) { -+ } -+ -+ /** -+ * Creates a recipe object from the provided path. -+ * -+ * @param string $path -+ * The path to a recipe. -+ * -+ * @return static -+ * The Recipe object. -+ */ -+ public static function createFromDirectory(string $path): static { -+ if (!is_readable($path . '/recipe.yml')) { -+ throw new RecipeFileException("There is no $path/recipe.yml file"); -+ } -+ -+ $recipe_contents = file_get_contents($path . '/recipe.yml'); -+ if (!$recipe_contents) { -+ throw new RecipeFileException("$path/recipe.yml cannot be read"); -+ } -+ $recipe_data = Yaml::decode($recipe_contents); -+ // @todo Do we need to improve validation? -+ if (!is_array($recipe_data)) { -+ throw new RecipeFileException("$path/recipe.yml is invalid"); -+ } -+ $recipe_data += [ -+ 'description' => '', -+ 'type' => '', -+ 'recipes' => [], -+ 'install' => [], -+ 'config' => [], -+ 'content' => [], -+ ]; -+ -+ if (!isset($recipe_data['name'])) { -+ throw new RecipeFileException("The $path/recipe.yml has no name value."); -+ } -+ -+ $recipe_discovery = new RecipeDiscovery([dirname($path)]); -+ $recipes = new RecipeConfigurator($recipe_data['recipes'], $recipe_discovery); -+ $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme')); -+ $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage')); -+ $content = new ContentConfigurator($recipe_data['content']); -+ return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php -new file mode 100644 -index 00000000..d58cf812 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php -@@ -0,0 +1,107 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\DrupalKernel; -+use Drupal\Core\Site\Settings; -+use Symfony\Component\Console\Command\Command; -+use Symfony\Component\Console\Input\InputArgument; -+use Symfony\Component\Console\Input\InputInterface; -+use Symfony\Component\Console\Output\OutputInterface; -+use Symfony\Component\Console\Style\SymfonyStyle; -+use Symfony\Component\HttpFoundation\Request; -+ -+/** -+ * Applies recipe. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeCommand extends Command { -+ -+ /** -+ * The class loader. -+ * -+ * @var object -+ */ -+ protected $classLoader; -+ -+ /** -+ * Constructs a new ServerCommand command. -+ * -+ * @param object $class_loader -+ * The class loader. -+ */ -+ public function __construct($class_loader) { -+ parent::__construct('recipe'); -+ $this->classLoader = $class_loader; -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ protected function configure(): void { -+ $this -+ ->setDescription('Applies a recipe to a site.') -+ ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder to apply'); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ protected function execute(InputInterface $input, OutputInterface $output): int { -+ $io = new SymfonyStyle($input, $output); -+ -+ if (PHP_VERSION_ID < 80100) { -+ $io->error('Recipes require PHP 8.1'); -+ return 1; -+ } -+ -+ $recipe_path = $input->getArgument('path'); -+ if (!is_string($recipe_path) || !is_dir($recipe_path)) { -+ $io->error(sprintf('The supplied path %s is not a directory', $recipe_path)); -+ } -+ -+ // Recipes have to be applied to installed sites. -+ $this->boot(); -+ -+ $recipe = Recipe::createFromDirectory($recipe_path); -+ RecipeRunner::processRecipe($recipe); -+ $io->success(sprintf('%s applied successfully', $recipe->name)); -+ return 0; -+ } -+ -+ /** -+ * Boots up a Drupal environment. -+ * -+ * @return \Drupal\Core\DrupalKernelInterface -+ * The Drupal kernel. -+ * -+ * @throws \Exception -+ * Exception thrown if kernel does not boot. -+ */ -+ protected function boot() { -+ $kernel = new DrupalKernel('prod', $this->classLoader, FALSE); -+ $kernel::bootEnvironment(); -+ $kernel->setSitePath($this->getSitePath()); -+ Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader); -+ $kernel->boot(); -+ $kernel->preHandle(Request::createFromGlobals()); -+ -+ return $kernel; -+ } -+ -+ /** -+ * Gets the site path. -+ * -+ * Defaults to 'sites/default'. For testing purposes this can be overridden -+ * using the DRUPAL_DEV_SITE_PATH environment variable. -+ * -+ * @return string -+ * The site path to use. -+ */ -+ protected function getSitePath() { -+ return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default'; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php -new file mode 100644 -index 00000000..70d6308d ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php -@@ -0,0 +1,63 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\ConfigInstaller; -+use Drupal\Core\Config\Entity\ConfigDependencyManager; -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * Extends the ConfigInstaller service for recipes. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeConfigInstaller extends ConfigInstaller { -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function installRecipeConfig(ConfigConfigurator $recipe_config): void { -+ $storage = $recipe_config->getConfigStorage(); -+ -+ // Build the list of possible configuration to create. -+ $list = $storage->listAll(); -+ -+ $enabled_extensions = $this->getEnabledExtensions(); -+ $existing_config = $this->getActiveStorages()->listAll(); -+ -+ // Filter the list of configuration to only include configuration that -+ // should be created. -+ $list = array_filter($list, function ($config_name) use ($existing_config) { -+ // Only list configuration that: -+ // - does not already exist -+ return !in_array($config_name, $existing_config); -+ }); -+ -+ // If there is nothing to do. -+ if (empty($list)) { -+ return; -+ } -+ -+ $all_config = array_merge($existing_config, $list); -+ $all_config = array_combine($all_config, $all_config); -+ $config_to_create = $storage->readMultiple($list); -+ -+ // Sort $config_to_create in the order of the least dependent first. -+ $dependency_manager = new ConfigDependencyManager(); -+ $dependency_manager->setData($config_to_create); -+ $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create); -+ -+ foreach ($config_to_create as $config_name => $data) { -+ if (!$this->validateDependencies($config_name, $data, $enabled_extensions, $all_config)) { -+ throw new RecipeUnmetDependenciesException($config_name, sprintf("The configuration '%s' has unmet dependencies", $config_name)); -+ } -+ } -+ -+ // Create the optional configuration if there is any left after filtering. -+ if (!empty($config_to_create)) { -+ $this->createConfiguration(StorageInterface::DEFAULT_COLLECTION, $config_to_create); -+ } -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php -new file mode 100644 -index 00000000..d2808d16 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php -@@ -0,0 +1,156 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\NullStorage; -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * Merges two storages together. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeConfigStorageWrapper implements StorageInterface { -+ -+ /** -+ * @param \Drupal\Core\Config\StorageInterface $storageA -+ * First config storage to wrap. -+ * @param \Drupal\Core\Config\StorageInterface $storageB -+ * Second config storage to wrap. -+ * @param string $collection -+ * (optional) The collection to store configuration in. Defaults to the -+ * default collection. -+ */ -+ public function __construct(protected readonly StorageInterface $storageA, protected readonly StorageInterface $storageB, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { -+ } -+ -+ /** -+ * Creates a single config storage for an array of storages. -+ * -+ * If the same configuration is contained in multiple storages then the -+ * version returned is from the first storage supplied in the $storages array. -+ * -+ * @param \Drupal\Core\Config\StorageInterface[] $storages -+ * An array of storages to merge into a single storage. -+ * -+ * @return \Drupal\Core\Config\StorageInterface -+ * A config storage that represents a merge of all the provided storages. -+ */ -+ public static function createStorageFromArray(array $storages): StorageInterface { -+ // If storages is empty use the NullStorage to represent an empty storage. -+ if (empty($storages)) { -+ return new NullStorage(); -+ } -+ -+ // When there is only one storage there is no point wrapping it. -+ if (count($storages) === 1) { -+ return reset($storages); -+ } -+ -+ // Reduce all the storages to a single RecipeConfigStorageWrapper object. -+ // The storages are prioritized in the order they are added to $storages. -+ return array_reduce($storages, fn(StorageInterface $carry, StorageInterface $storage) => new static($carry, $storage), new static( -+ array_shift($storages), -+ array_shift($storages) -+ )); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function exists($name): bool { -+ return $this->storageA->exists($name) || $this->storageB->exists($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function read($name): array|bool { -+ return $this->storageA->read($name) ?: $this->storageB->read($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function readMultiple(array $names): array { -+ // If both storageA and storageB contain the same configuration, the value -+ // for storageA takes precedence. -+ return array_merge($this->storageB->readMultiple($names), $this->storageA->readMultiple($names)); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function write($name, array $data): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function delete($name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function rename($name, $new_name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function encode($data): string { -+ return $this->storageA->encode($data); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function decode($raw): array { -+ return $this->storageA->decode($raw); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function listAll($prefix = ''): array { -+ return array_unique(array_merge($this->storageA->listAll($prefix), $this->storageB->listAll($prefix))); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function deleteAll($prefix = ''): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function createCollection($collection): static { -+ return new static( -+ $this->storageA->createCollection($collection), -+ $this->storageB->createCollection($collection), -+ $collection -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getAllCollectionNames(): array { -+ return array_unique(array_merge($this->storageA->getAllCollectionNames(), $this->storageB->getAllCollectionNames())); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getCollectionName(): string { -+ return $this->collection; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php -new file mode 100644 -index 00000000..f541675e ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php -@@ -0,0 +1,24 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeConfigurator { -+ -+ public readonly array $recipes; -+ -+ /** -+ * @param string[] $recipes -+ * A list of recipes for a recipe to apply. The recipes will be applied in -+ * the order listed. -+ * @param \Drupal\Core\Recipe\RecipeDiscovery $recipeDiscovery -+ * Recipe discovery. -+ */ -+ public function __construct(array $recipes, RecipeDiscovery $recipeDiscovery) { -+ $this->recipes = array_map([$recipeDiscovery, 'getRecipe'], $recipes); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php -new file mode 100644 -index 00000000..a83025bb ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php -@@ -0,0 +1,46 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Component\Assertion\Inspector; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeDiscovery { -+ -+ /** -+ * Constructs a recipe discovery object. -+ * -+ * @param array $paths -+ * An array of paths where to search for recipes. The path will be searched -+ * folders containing a recipe.yml. There will be no traversal further into -+ * the directory structure. -+ */ -+ public function __construct(protected readonly array $paths) { -+ assert(Inspector::assertAllStrings($paths), 'Paths must be strings.'); -+ } -+ -+ /** -+ * Constructs a RecipeDiscovery object. -+ * -+ * @param string $name -+ * The machine name of the recipe to find. -+ * -+ * @return \Drupal\Core\Recipe\Recipe -+ * The recipe object. -+ * -+ * @throws \Drupal\Core\Recipe\UnknownRecipeException -+ * Thrown when the recipe cannot be found. -+ */ -+ public function getRecipe(string $name): Recipe { -+ foreach ($this->paths as $path) { -+ if (file_exists($path . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'recipe.yml')) { -+ return Recipe::createFromDirectory($path . DIRECTORY_SEPARATOR . $name); -+ } -+ } -+ throw new UnknownRecipeException($name, $this->paths, sprintf("Can not find the %s recipe, search paths: %s", $name, implode(', ', $this->paths))); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php -new file mode 100644 -index 00000000..4dfaf80d ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php -@@ -0,0 +1,144 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\FileStorage; -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * Allows the recipe to select configuration from the module. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeExtensionConfigStorage implements StorageInterface { -+ -+ protected readonly StorageInterface $storage; -+ -+ /** -+ * @param string $extensionPath -+ * The path extension to read configuration from -+ * @param array $configNames -+ * The list of config to read from the extension. An empty array means all -+ * configuration. -+ * @param string $collection -+ * (optional) The collection to store configuration in. Defaults to the -+ * default collection. -+ */ -+ public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { -+ $this->storage = new RecipeConfigStorageWrapper( -+ new FileStorage($this->extensionPath . '/config/install', $this->collection), -+ new FileStorage($this->extensionPath . '/config/optional', $this->collection), -+ $collection -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function exists($name): bool { -+ if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { -+ return FALSE; -+ } -+ return $this->storage->exists($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function read($name): array|bool { -+ if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { -+ return FALSE; -+ } -+ return $this->storage->read($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function readMultiple(array $names): array { -+ if (!empty($this->configNames)) { -+ $names = array_intersect($this->configNames, $names); -+ } -+ return $this->storage->readMultiple($names); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function write($name, array $data): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function delete($name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function rename($name, $new_name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function encode($data): string { -+ return $this->storage->encode($data); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function decode($raw): array { -+ return $this->storage->decode($raw); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function listAll($prefix = ''): array { -+ $names = $this->storage->listAll($prefix); -+ if (!empty($this->configNames)) { -+ $names = array_intersect($this->configNames, $names); -+ } -+ return $names; -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function deleteAll($prefix = ''): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function createCollection($collection): static { -+ return new static( -+ $this->extensionPath, -+ $this->configNames, -+ $collection -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getAllCollectionNames(): array { -+ return $this->storage->getAllCollectionNames(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getCollectionName(): string { -+ return $this->collection; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeFileException.php b/core/lib/Drupal/Core/Recipe/RecipeFileException.php -new file mode 100644 -index 00000000..e3f6cffc ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeFileException.php -@@ -0,0 +1,11 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeFileException extends \RuntimeException { -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php -new file mode 100644 -index 00000000..e927466a ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php -@@ -0,0 +1,32 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Component\Assertion\Inspector; -+ -+/** -+ * Exception thrown when recipes contain or depend on missing extensions. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeMissingExtensionsException extends \RuntimeException { -+ -+ /** -+ * Constructs a RecipeMissingExtensionsException. -+ * -+ * @param array $extensions -+ * The list of missing extensions. -+ * @param string $message -+ * [optional] The Exception message to throw. -+ * @param int $code -+ * [optional] The Exception code. -+ * @param null|\Throwable $previous -+ * [optional] The previous throwable used for the exception chaining. -+ */ -+ public function __construct(public readonly array $extensions, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { -+ assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.'); -+ parent::__construct($message, $code, $previous); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php -new file mode 100644 -index 00000000..02cdca54 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php -@@ -0,0 +1,131 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * Wraps a config storage to allow recipe provided configuration to override it. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeOverrideConfigStorage implements StorageInterface { -+ -+ /** -+ * @param \Drupal\Core\Config\StorageInterface $recipeStorage -+ * The recipe's configuration storage. -+ * @param \Drupal\Core\Config\StorageInterface $wrappedStorage -+ * The storage to override. -+ * @param string $collection -+ * (optional) The collection to store configuration in. Defaults to the -+ * default collection. -+ */ -+ public function __construct(protected readonly StorageInterface $recipeStorage, protected readonly StorageInterface $wrappedStorage, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function exists($name): bool { -+ return $this->wrappedStorage->exists($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function read($name): array|bool { -+ if ($this->wrappedStorage->exists($name) && $this->recipeStorage->exists($name)) { -+ return $this->recipeStorage->read($name); -+ } -+ return $this->wrappedStorage->read($name); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function readMultiple(array $names): array { -+ $data = $this->wrappedStorage->readMultiple($names); -+ foreach ($data as $name => $value) { -+ if ($this->recipeStorage->exists($name)) { -+ $data[$name] = $this->recipeStorage->read($name); -+ } -+ } -+ return $data; -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function write($name, array $data): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function delete($name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function rename($name, $new_name): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function encode($data): string { -+ return $this->wrappedStorage->encode($data); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function decode($raw): array { -+ return $this->wrappedStorage->decode($raw); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function listAll($prefix = ''): array { -+ return $this->wrappedStorage->listAll($prefix); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function deleteAll($prefix = ''): bool { -+ throw new \BadMethodCallException(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function createCollection($collection): static { -+ return new static( -+ $this->recipeStorage->createCollection($collection), -+ $this->wrappedStorage->createCollection($collection), -+ $collection -+ ); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getAllCollectionNames(): array { -+ return $this->wrappedStorage->getAllCollectionNames(); -+ } -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function getCollectionName(): string { -+ return $this->collection; -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php -new file mode 100644 -index 00000000..ce0bd543 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php -@@ -0,0 +1,26 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * Exception thrown when a recipe has configuration that exists already. -+ */ -+class RecipePreExistingConfigException extends \RuntimeException { -+ -+ /** -+ * Constructs a RecipePreExistingConfigException. -+ * -+ * @param string $configName -+ * The configuration name that has missing dependencies. -+ * @param string $message -+ * [optional] The Exception message to throw. -+ * @param int $code -+ * [optional] The Exception code. -+ * @param null|\Throwable $previous -+ * [optional] The previous throwable used for the exception chaining. -+ */ -+ public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { -+ parent::__construct($message, $code, $previous); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php -new file mode 100644 -index 00000000..5670c1f8 ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php -@@ -0,0 +1,124 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+use Drupal\Core\Config\FileStorage; -+use Drupal\Core\Config\InstallStorage; -+use Drupal\Core\Config\StorageInterface; -+ -+/** -+ * Applies a recipe. -+ * -+ * This class currently static and use \Drupal::service() in order to put off -+ * having to solve issues caused by container rebuilds during module install and -+ * configuration import. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeRunner { -+ -+ /** -+ * @param \Drupal\Core\Recipe\Recipe $recipe -+ * The recipe to apply. -+ */ -+ public static function processRecipe(Recipe $recipe): void { -+ static::processRecipes($recipe->recipes); -+ static::processInstall($recipe->install, $recipe->config->getConfigStorage()); -+ static::processConfiguration($recipe->config); -+ static::processContent($recipe->content); -+ } -+ -+ /** -+ * Applies any recipes listed by the recipe. -+ * -+ * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes -+ * The list of recipes to apply. -+ */ -+ protected static function processRecipes(RecipeConfigurator $recipes): void { -+ foreach ($recipes->recipes as $recipe) { -+ static::processRecipe($recipe); -+ } -+ } -+ -+ /** -+ * Installs the extensions. -+ * -+ * @param \Drupal\Core\Recipe\InstallConfigurator $install -+ * The list of extensions to install. -+ * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage -+ * The recipe's configuration storage. Used to override extension provided -+ * configuration. -+ */ -+ protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void { -+ foreach ($install->modules as $name) { -+ // Disable configuration entity install but use the config directory from -+ // the module. -+ \Drupal::service('config.installer')->setSyncing(TRUE); -+ $default_install_path = \Drupal::service('extension.list.module')->get($name)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; -+ // Allow the recipe to override simple configuration from the module. -+ $storage = new RecipeOverrideConfigStorage( -+ $recipeConfigStorage, -+ new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION) -+ ); -+ \Drupal::service('config.installer')->setSourceStorage($storage); -+ -+ \Drupal::service('module_installer')->install([$name]); -+ \Drupal::service('config.installer')->setSyncing(FALSE); -+ } -+ -+ // Themes can depend on modules so have to be installed after modules. -+ foreach ($install->themes as $name) { -+ // Disable configuration entity install. -+ \Drupal::service('config.installer')->setSyncing(TRUE); -+ $default_install_path = \Drupal::service('extension.list.theme')->get($name)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; -+ // Allow the recipe to override simple configuration from the theme. -+ $storage = new RecipeOverrideConfigStorage( -+ $recipeConfigStorage, -+ new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION) -+ ); -+ \Drupal::service('config.installer')->setSourceStorage($storage); -+ -+ \Drupal::service('theme_installer')->install([$name]); -+ \Drupal::service('config.installer')->setSyncing(FALSE); -+ } -+ } -+ -+ /** -+ * Creates configuration and applies configuration actions. -+ * -+ * @param \Drupal\Core\Recipe\ConfigConfigurator $config -+ * The config configurator from the recipe. -+ */ -+ protected static function processConfiguration(ConfigConfigurator $config): void { -+ // @todo sort out this monstrosity. -+ $config_installer = new RecipeConfigInstaller( -+ \Drupal::service('config.factory'), -+ \Drupal::service('config.storage'), -+ \Drupal::service('config.typed'), -+ \Drupal::service('config.manager'), -+ \Drupal::service('event_dispatcher'), -+ NULL, -+ \Drupal::service('extension.path.resolver')); -+ -+ // Create configuration that is either supplied by the recipe or listed in -+ // the config.import section that does not exist. -+ $config_installer->installRecipeConfig($config); -+ -+ if (!empty($config->config['actions'])) { -+ // Process the actions. -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */ -+ $config_action_manager = \Drupal::service('plugin.manager.config_action'); -+ foreach ($config->config['actions'] as $config_name => $actions) { -+ foreach ($actions as $action_id => $data) { -+ $config_action_manager->applyAction($action_id, $config_name, $data); -+ } -+ } -+ } -+ } -+ -+ protected static function processContent(ContentConfigurator $content): void { -+ // @todo https://www.drupal.org/project/distributions_recipes/issues/3292287 -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php b/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php -new file mode 100644 -index 00000000..71fcdc6b ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php -@@ -0,0 +1,29 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * Exception thrown when a recipe has configuration with unmet dependencies. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class RecipeUnmetDependenciesException extends \RuntimeException { -+ -+ /** -+ * Constructs a RecipeUnmetDependenciesException. -+ * -+ * @param string $configName -+ * The configuration name that has missing dependencies. -+ * @param string $message -+ * [optional] The Exception message to throw. -+ * @param int $code -+ * [optional] The Exception code. -+ * @param null|\Throwable $previous -+ * [optional] The previous throwable used for the exception chaining. -+ */ -+ public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { -+ parent::__construct($message, $code, $previous); -+ } -+ -+} -diff --git a/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php -new file mode 100644 -index 00000000..9351ba4d ---- /dev/null -+++ b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php -@@ -0,0 +1,29 @@ -+<?php -+ -+namespace Drupal\Core\Recipe; -+ -+/** -+ * Exception thrown when recipe is can not be found. -+ * -+ * @internal -+ * This API is experimental. -+ */ -+final class UnknownRecipeException extends \RuntimeException { -+ -+ /** -+ * @param string $recipe -+ * The recipe's name. -+ * @param array $searchPaths -+ * The paths searched for the recipe. -+ * @param string $message -+ * (optional) The exception message. -+ * @param int $code -+ * (optional) The exception code. -+ * @param \Throwable|null $previous -+ * (optional) The previous exception. -+ */ -+ public function __construct(public readonly string $recipe, public readonly array $searchPaths, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { -+ parent::__construct($message, $code, $previous); -+ } -+ -+} -diff --git a/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml -new file mode 100644 -index 00000000..2f1dd51d ---- /dev/null -+++ b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml -@@ -0,0 +1,4 @@ -+name: 'Config action duplicate test' -+type: module -+package: Testing -+version: VERSION -diff --git a/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php -new file mode 100644 -index 00000000..d19474c6 ---- /dev/null -+++ b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php -@@ -0,0 +1,26 @@ -+<?php -+ -+namespace Drupal\config_duplicate_action_test\Plugin\ConfigAction; -+ -+use Drupal\Core\Config\Action\ConfigActionPluginInterface; -+ -+/** -+ * @ConfigAction( -+ * id = "config_action_duplicate_test:config_test.dynamic:setProtectedProperty", -+ * admin_label = @Translation("A duplicate config action"), -+ * entity_types = { -+ * "config_test" -+ * } -+ * ) -+ */ -+final class DuplicateConfigAction implements ConfigActionPluginInterface { -+ -+ /** -+ * {@inheritdoc} -+ */ -+ public function apply(string $configName, mixed $value): void { -+ // This method should never be called. -+ throw new \BadMethodCallException(); -+ } -+ -+} -diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml -index 2a707fac..b6b59ec5 100644 ---- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml -+++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml -@@ -24,6 +24,9 @@ config_test_dynamic: - protected_property: - type: string - label: 'Protected property' -+ array_property: -+ type: ignore -+ label: 'Array property' - - config_test.dynamic.*: - type: config_test_dynamic -diff --git a/core/modules/config/tests/config_test/config_test.module b/core/modules/config/tests/config_test/config_test.module -index ac06237e..24647088 100644 ---- a/core/modules/config/tests/config_test/config_test.module -+++ b/core/modules/config/tests/config_test/config_test.module -@@ -40,4 +40,8 @@ function config_test_entity_type_alter(array &$entity_types) { - if (\Drupal::service('state')->get('config_test.lookup_keys', FALSE)) { - $entity_types['config_test']->set('lookup_keys', ['uuid', 'style']); - } -+ -+ if (\Drupal::service('state')->get('config_test.class_override', FALSE)) { -+ $entity_types['config_test']->setClass(\Drupal::service('state')->get('config_test.class_override')); -+ } - } -diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php -new file mode 100644 -index 00000000..b51e41af ---- /dev/null -+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php -@@ -0,0 +1,17 @@ -+<?php -+ -+namespace Drupal\config_test\ConfigActionErrorEntity; -+ -+use Drupal\config_test\Entity\ConfigTest; -+use Drupal\Core\Config\Action\Attribute\ActionMethod; -+ -+/** -+ * Test entity class. -+ */ -+class DuplicatePluralizedMethodName extends ConfigTest { -+ -+ #[ActionMethod(pluralize: 'testMethod')] -+ public function testMethod() { -+ } -+ -+} -diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php -new file mode 100644 -index 00000000..98c5d075 ---- /dev/null -+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php -@@ -0,0 +1,21 @@ -+<?php -+ -+namespace Drupal\config_test\ConfigActionErrorEntity; -+ -+use Drupal\config_test\Entity\ConfigTest; -+use Drupal\Core\Config\Action\Attribute\ActionMethod; -+ -+/** -+ * Test entity class. -+ */ -+class DuplicatePluralizedOtherMethodName extends ConfigTest { -+ -+ #[ActionMethod(pluralize: 'testMethod2')] -+ public function testMethod() { -+ } -+ -+ #[ActionMethod()] -+ public function testMethod2() { -+ } -+ -+} -diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php -index 6b9e8007..b461828a 100644 ---- a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php -+++ b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php -@@ -46,4 +46,13 @@ class ConfigQueryTest extends ConfigTest { - */ - public $array = []; - -+ /** -+ * {@inheritdoc} -+ */ -+ public function concatProtectedProperty(string $value1, string $value2): static { -+ // This method intentionally does not have the config action attribute to -+ // ensure it is still discovered. -+ return parent::concatProtectedProperty($value1, $value2); -+ } -+ - } -diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php -index f8ceea42..5f446c4d 100644 ---- a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php -+++ b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php -@@ -2,10 +2,12 @@ - - namespace Drupal\config_test\Entity; - -+use Drupal\Core\Config\Action\Attribute\ActionMethod; - use Drupal\Core\Config\Entity\ConfigEntityBase; - use Drupal\config_test\ConfigTestInterface; - use Drupal\Core\Config\Entity\ConfigEntityInterface; - use Drupal\Core\Entity\EntityStorageInterface; -+use Drupal\Core\StringTranslation\TranslatableMarkup; - - /** - * Defines the ConfigTest configuration entity. -@@ -36,6 +38,7 @@ - * "size", - * "size_value", - * "protected_property", -+ * "array_property", - * }, - * links = { - * "edit-form" = "/admin/structure/config_test/manage/{config_test}", -@@ -83,6 +86,13 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface { - */ - protected $protected_property; - -+ /** -+ * An array property of the configuration entity. -+ * -+ * @var array -+ */ -+ protected array $array_property = []; -+ - /** - * {@inheritdoc} - */ -@@ -188,4 +198,130 @@ public function isInstallable() { - return $this->id != 'isinstallable' || \Drupal::state()->get('config_test.isinstallable'); - } - -+ /** -+ * Sets the protected property value. -+ * -+ * @param $value -+ * The value to set. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod(pluralize: FALSE)] -+ public function setProtectedProperty(string $value): static { -+ $this->protected_property = $value; -+ return $this; -+ } -+ -+ /** -+ * Gets the protected property value. -+ * -+ * @return string -+ * The protected property value. -+ */ -+ public function getProtectedProperty(): string { -+ return $this->protected_property; -+ } -+ -+ /** -+ * Concatenates the two params and sets the protected property value. -+ * -+ * @param $value1 -+ * The first value to concatenate. -+ * @param $value2 -+ * The second value to concatenate. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod()] -+ public function concatProtectedProperty(string $value1, string $value2): static { -+ $this->protected_property = $value1 . $value2; -+ return $this; -+ } -+ -+ /** -+ * Concatenates up to two params and sets the protected property value. -+ * -+ * @param $value1 -+ * The first value to concatenate. -+ * @param $value2 -+ * (optional) The second value to concatenate. Defaults to ''. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod(pluralize: FALSE)] -+ public function concatProtectedPropertyOptional(string $value1, string $value2 = ''): static { -+ $this->protected_property = $value1 . $value2; -+ return $this; -+ } -+ -+ /** -+ * Appends to protected property. -+ * -+ * @param $value -+ * The value to append. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod()] -+ public function append(string $value): static { -+ $this->protected_property .= $value; -+ return $this; -+ } -+ -+ /** -+ * Sets the protected property to a default value. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod(pluralize: FALSE, adminLabel: new TranslatableMarkup('Set default name'))] -+ public function defaultProtectedProperty(): static { -+ $this->protected_property = 'Set by method'; -+ return $this; -+ } -+ -+ /** -+ * Adds a value to the array property. -+ * -+ * @param mixed $value -+ * The value to add. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod(pluralize: 'addToArrayMultipleTimes')] -+ public function addToArray(mixed $value): static { -+ $this->array_property[] = $value; -+ return $this; -+ } -+ -+ /** -+ * Gets the array property value. -+ * -+ * @return array -+ * The array property value. -+ */ -+ public function getArrayProperty(): array { -+ return $this->array_property; -+ } -+ -+ /** -+ * Sets the array property. -+ * -+ * @param $value -+ * The value to set. -+ * -+ * @return $this -+ * The config entity. -+ */ -+ #[ActionMethod(pluralize: FALSE)] -+ public function setArray(array $value): static { -+ $this->array_property = $value; -+ return $this; -+ } -+ - } -diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php -index cf03af72..aa324306 100644 ---- a/core/modules/filter/src/Entity/FilterFormat.php -+++ b/core/modules/filter/src/Entity/FilterFormat.php -@@ -3,9 +3,11 @@ - namespace Drupal\filter\Entity; - - use Drupal\Component\Plugin\PluginInspectionInterface; -+use Drupal\Core\Config\Action\Attribute\ActionMethod; - use Drupal\Core\Config\Entity\ConfigEntityBase; - use Drupal\Core\Entity\EntityWithPluginCollectionInterface; - use Drupal\Core\Entity\EntityStorageInterface; -+use Drupal\Core\StringTranslation\TranslatableMarkup; - use Drupal\filter\FilterFormatInterface; - use Drupal\filter\FilterPluginCollection; - use Drupal\filter\Plugin\FilterInterface; -@@ -159,6 +161,7 @@ public function getPluginCollections() { - /** - * {@inheritdoc} - */ -+ #[ActionMethod(adminLabel: new TranslatableMarkup('Sets configuration for a filter plugin'))] - public function setFilterConfig($instance_id, array $configuration) { - $this->filters[$instance_id] = $configuration; - if (isset($this->filterCollection)) { -diff --git a/core/modules/user/src/Entity/Role.php b/core/modules/user/src/Entity/Role.php -index 18194f33..68ddd501 100644 ---- a/core/modules/user/src/Entity/Role.php -+++ b/core/modules/user/src/Entity/Role.php -@@ -2,8 +2,10 @@ - - namespace Drupal\user\Entity; - -+use Drupal\Core\Config\Action\Attribute\ActionMethod; - use Drupal\Core\Config\Entity\ConfigEntityBase; - use Drupal\Core\Entity\EntityStorageInterface; -+use Drupal\Core\StringTranslation\TranslatableMarkup; - use Drupal\user\RoleInterface; - - /** -@@ -126,6 +128,7 @@ public function hasPermission($permission) { - /** - * {@inheritdoc} - */ -+ #[ActionMethod(adminLabel: new TranslatableMarkup('Add permission to role'))] - public function grantPermission($permission) { - if ($this->isAdmin()) { - return $this; -diff --git a/core/recipes/example/composer.json b/core/recipes/example/composer.json -new file mode 100644 -index 00000000..1d231ba7 ---- /dev/null -+++ b/core/recipes/example/composer.json -@@ -0,0 +1,9 @@ -+{ -+ "name": "drupal_recipe/example", -+ "description": "An example Drupal recipe description", -+ "type": "drupal-recipe", -+ "require": { -+ "drupal/core": "^10.0.x-dev" -+ }, -+ "license": "GPL-2.0-or-later" -+} -diff --git a/core/recipes/example/recipe.yml b/core/recipes/example/recipe.yml -new file mode 100644 -index 00000000..2103651c ---- /dev/null -+++ b/core/recipes/example/recipe.yml -@@ -0,0 +1,46 @@ -+# The type key is similar to the package key in module.info.yml. It -+# can be used by the UI to group Drupal recipes. Additionally, -+# the type 'Site' means that the Drupal recipe will be listed in -+# the installer. -+type: 'Content type' -+ -+install: -+ # An array of modules or themes to install, if they are not already. -+ # The system will detect if it is a theme or a module. During the -+ # install only simple configuration from the new modules is created. -+ # This allows the Drupal recipe control over the configuration. -+ - node -+ - text -+ -+config: -+ # A Drupal recipe can have a config directory. All configuration -+ # is this directory will be imported after the modules have been -+ # installed. -+ -+ # Additionally, the Drupal recipe can install configuration entities -+ # provided by the modules it configures. This allows them to not have -+ # to maintain or copy this configuration. Note the examples below are -+ # fictitious. -+ import: -+ node: -+ - node.type.article -+ # Import all configuration that is provided by the text module and any -+ # optional configuration that depends on the text module that is provided by -+ # modules already installed. -+ text: * -+ -+ # Configuration actions may be defined. The structure here should be -+ # entity_type.ID.action. Below the user role entity type with an ID of -+ # editor is having the permissions added. The permissions key will be -+ # mapped to the \Drupal\user\Entity\Role::grantPermission() method. -+ actions: -+ user.role.editor: -+ ensure_exists: -+ label: 'Editor' -+ grantPermissions: -+ - 'delete any article content' -+ - 'edit any article content' -+ -+content: -+# A Drupal recipe can have a content directory. All content in this -+# directory will be created after the configuration is installed. -diff --git a/core/scripts/drupal b/core/scripts/drupal -index 891d5b81..0c9eb300 100644 ---- a/core/scripts/drupal -+++ b/core/scripts/drupal -@@ -10,6 +10,7 @@ use Drupal\Core\Command\GenerateTheme; - use Drupal\Core\Command\QuickStartCommand; - use Drupal\Core\Command\InstallCommand; - use Drupal\Core\Command\ServerCommand; -+use Drupal\Core\Recipe\RecipeCommand; - use Symfony\Component\Console\Application; - - if (PHP_SAPI !== 'cli') { -@@ -24,5 +25,6 @@ $application->add(new QuickStartCommand()); - $application->add(new InstallCommand($classloader)); - $application->add(new ServerCommand($classloader)); - $application->add(new GenerateTheme()); -+$application->add(new RecipeCommand($classloader)); - - $application->run(); -diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php -new file mode 100644 -index 00000000..28071db5 ---- /dev/null -+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php -@@ -0,0 +1,57 @@ -+<?php -+ -+namespace Drupal\FunctionalTests\Core\Recipe; -+ -+use Drupal\Tests\BrowserTestBase; -+use Symfony\Component\Process\PhpExecutableFinder; -+use Symfony\Component\Process\Process; -+ -+/** -+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand -+ * @group Recipe -+ * -+ * BrowserTestBase is used for a proper Drupal install. -+ */ -+class RecipeCommandTest extends BrowserTestBase { -+ -+ /** -+ * {@inheritdoc} -+ */ -+ protected $defaultTheme = 'stark'; -+ -+ public function testRecipeCommand(): void { -+ $this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed'); -+ $php_executable_finder = new PhpExecutableFinder(); -+ $php = $php_executable_finder->find(); -+ -+ $recipe_command = [ -+ $php, -+ 'core/scripts/drupal', -+ 'recipe', -+ 'core/tests/fixtures/recipes/install_node_with_config', -+ ]; -+ $process = new Process($recipe_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->siteDirectory]); -+ $process->setTimeout(500); -+ $status = $process->run(); -+ $this->assertSame(0, $status); -+ $this->assertSame('', $process->getErrorOutput()); -+ $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput()); -+ -+ $this->rebuildAll(); -+ $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed'); -+ -+ // Ensure recipes that fail have an exception message. -+ $recipe_command = [ -+ $php, -+ 'core/scripts/drupal', -+ 'recipe', -+ 'core/tests/fixtures/recipes/missing_extensions', -+ ]; -+ $process = new Process($recipe_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->siteDirectory]); -+ $process->setTimeout(500); -+ $status = $process->run(); -+ $this->assertSame(1, $status); -+ $this->assertStringContainsString('Drupal\Core\Recipe\RecipeMissingExtensionsException', $process->getErrorOutput()); -+ } -+ -+} -diff --git a/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php -new file mode 100644 -index 00000000..13fd4e91 ---- /dev/null -+++ b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php -@@ -0,0 +1,324 @@ -+<?php -+ -+namespace Drupal\KernelTests\Core\Config\Action; -+ -+// cspell:ignore inflector -+use Drupal\Component\Plugin\Exception\PluginNotFoundException; -+use Drupal\Component\Uuid\Uuid; -+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName; -+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName; -+use Drupal\Core\Config\Action\ConfigActionException; -+use Drupal\Core\Config\Action\DuplicateConfigActionIdException; -+use Drupal\Core\Config\Action\EntityMethodException; -+use Drupal\KernelTests\KernelTestBase; -+ -+/** -+ * Tests the config action system. -+ * -+ * @group config -+ */ -+class ConfigActionTest extends KernelTestBase { -+ -+ /** -+ * Modules to enable. -+ * -+ * @var array -+ */ -+ protected static $modules = ['config_test']; -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate -+ */ -+ public function testEntityCreate(): void { -+ $this->assertCount(0, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There are no config_test entities'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']); -+ /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */ -+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); -+ $this->assertCount(1, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There is 1 config_test entity'); -+ $this->assertSame('Action test', $config_test_entities['action_test']->label()); -+ $this->assertTrue(Uuid::isValid((string) $config_test_entities['action_test']->uuid()), 'Config entity assigned a valid UUID'); -+ -+ // Calling ensure exists action again will not error. -+ $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']); -+ -+ try { -+ $manager->applyAction('entity_create:create', 'config_test.dynamic.action_test', ['label' => 'Action test']); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (ConfigActionException $e) { -+ $this->assertSame('Entity config_test.dynamic.action_test exists', $e->getMessage()); -+ } -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod -+ */ -+ public function testEntityMethod(): void { -+ $this->installConfig('config_test'); -+ $storage = \Drupal::entityTypeManager()->getStorage('config_test'); -+ -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Default', $config_test_entity->getProtectedProperty()); -+ -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ // Call a method action. -+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value', $config_test_entity->getProtectedProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value 2'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value 2', $config_test_entity->getProtectedProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', ['Test value ', '3']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value 3', $config_test_entity->getProtectedProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value ', '4']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value 4', $config_test_entity->getProtectedProperty()); -+ -+ // Test calling an action that has 2 arguments but one is optional with an -+ // array value. -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value 5']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value 5', $config_test_entity->getProtectedProperty()); -+ -+ // Test calling an action that has 2 arguments but one is optional with a -+ // non array value. -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', 'Test value 6'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Test value 6', $config_test_entity->getProtectedProperty()); -+ -+ // Test calling an action that expects no arguments. -+ $manager->applyAction('entity_method:config_test.dynamic:defaultProtectedProperty', 'config_test.dynamic.dotted.default', []); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('Set by method', $config_test_entity->getProtectedProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'foo'); -+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'bar'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame(['foo', 'bar'], $config_test_entity->getArrayProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame(['foo', 'bar', ['a', 'b', 'c']], $config_test_entity->getArrayProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame(['a', 'b', 'c'], $config_test_entity->getArrayProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', [['a', 'b', 'c'], ['a']]); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame([['a', 'b', 'c'], ['a']], $config_test_entity->getArrayProperty()); -+ -+ $config_test_entity->delete(); -+ try { -+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (ConfigActionException $e) { -+ $this->assertSame('Entity config_test.dynamic.dotted.default does not exist', $e->getMessage()); -+ } -+ -+ // Test custom and default admin labels. -+ $this->assertSame('Test configuration append', (string) $manager->getDefinition('entity_method:config_test.dynamic:append')['admin_label']); -+ $this->assertSame('Set default name', (string) $manager->getDefinition('entity_method:config_test.dynamic:defaultProtectedProperty')['admin_label']); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod -+ */ -+ public function testPluralizedEntityMethod(): void { -+ $this->installConfig('config_test'); -+ $storage = \Drupal::entityTypeManager()->getStorage('config_test'); -+ -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ // Call a pluralized method action. -+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', ['a', 'b', 'c', 'd']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame(['a', 'b', 'c', 'd'], $config_test_entity->getArrayProperty()); -+ -+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', [['foo'], 'bar']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame(['a', 'b', 'c', 'd', ['foo'], 'bar'], $config_test_entity->getArrayProperty()); -+ -+ $config_test_entity->setProtectedProperty('')->save(); -+ $manager->applyAction('entity_method:config_test.dynamic:appends', 'config_test.dynamic.dotted.default', ['1', '2', '3']); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('123', $config_test_entity->getProtectedProperty()); -+ -+ // Test that the inflector converts to a good plural form. -+ $config_test_entity->setProtectedProperty('')->save(); -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperties', 'config_test.dynamic.dotted.default', [['1', '2'], ['3', '4']]); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('dotted.default'); -+ $this->assertSame('34', $config_test_entity->getProtectedProperty()); -+ -+ $this->assertTrue($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedProperty'), 'The setProtectedProperty action exists'); -+ // cspell:ignore Propertys -+ $this->assertFalse($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedPropertys'), 'There is no automatically pluralized version of the setProtectedProperty action'); -+ -+ // Admin label for pluralized form. -+ $this->assertSame('Test configuration append (multiple calls)', (string) $manager->getDefinition('entity_method:config_test.dynamic:appends')['admin_label']); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod -+ */ -+ public function testPluralizedEntityMethodException(): void { -+ $this->installConfig('config_test'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $this->expectException(EntityMethodException::class); -+ $this->expectExceptionMessage('The pluralized entity method config action \'entity_method:config_test.dynamic:addToArrayMultipleTimes\' requires an array value in order to call Drupal\config_test\Entity\ConfigTest::addToArray() multiple times'); -+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', 'Test value'); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver -+ */ -+ public function testDuplicatePluralizedMethodNameException(): void { -+ \Drupal::state()->set('config_test.class_override', DuplicatePluralizedMethodName::class); -+ \Drupal::entityTypeManager()->clearCachedDefinitions(); -+ $this->installConfig('config_test'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $this->expectException(EntityMethodException::class); -+ $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName::testMethod(). The existing action is for the ::testMethod() method'); -+ $manager->getDefinitions(); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver -+ */ -+ public function testDuplicatePluralizedOtherMethodNameException(): void { -+ \Drupal::state()->set('config_test.class_override', DuplicatePluralizedOtherMethodName::class); -+ \Drupal::entityTypeManager()->clearCachedDefinitions(); -+ $this->installConfig('config_test'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $this->expectException(EntityMethodException::class); -+ $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod2\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName::testMethod2(). The existing action is for the ::testMethod() method'); -+ $manager->getDefinitions(); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod -+ */ -+ public function testEntityMethodException(): void { -+ $this->installConfig('config_test'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $this->expectException(EntityMethodException::class); -+ $this->expectExceptionMessage('Entity method config action \'entity_method:config_test.dynamic:concatProtectedProperty\' requires an array value. The number of parameters or required parameters for Drupal\config_test\Entity\ConfigTest::concatProtectedProperty() is not 1'); -+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate -+ */ -+ public function testSimpleConfigUpdate(): void { -+ $this->installConfig('config_test'); -+ $this->assertSame('bar', $this->config('config_test.system')->get('foo')); -+ -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ // Call the simple config update action. -+ $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']); -+ $this->assertSame('Yay!', $this->config('config_test.system')->get('foo')); -+ -+ try { -+ $manager->applyAction('simple_config_update', 'config_test.system', 'Test'); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (ConfigActionException $e) { -+ $this->assertSame('Config config_test.system can not be updated because $value is not an array', $e->getMessage()); -+ } -+ -+ $this->config('config_test.system')->delete(); -+ try { -+ $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (ConfigActionException $e) { -+ $this->assertSame('Config config_test.system does not exist so can not be updated', $e->getMessage()); -+ } -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() -+ */ -+ public function testShorthandActionIds(): void { -+ $storage = \Drupal::entityTypeManager()->getStorage('config_test'); -+ $this->assertCount(0, $storage->loadMultiple(), 'There are no config_test entities'); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']); -+ /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */ -+ $config_test_entities = $storage->loadMultiple(); -+ $this->assertCount(1, $config_test_entities, 'There is 1 config_test entity'); -+ $this->assertSame('Action test', $config_test_entities['action_test']->label()); -+ -+ $this->assertSame('', $config_test_entities['action_test']->getProtectedProperty()); -+ -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ // Call a method action. -+ $manager->applyAction('setProtectedProperty', 'config_test.dynamic.action_test', 'Test value'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('action_test'); -+ $this->assertSame('Test value', $config_test_entity->getProtectedProperty()); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() -+ */ -+ public function testDuplicateShorthandActionIds(): void { -+ $this->enableModules(['config_action_duplicate_test']); -+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ -+ $manager = $this->container->get('plugin.manager.config_action'); -+ $this->expectException(DuplicateConfigActionIdException::class); -+ $this->expectExceptionMessage("The plugins 'entity_method:config_test.dynamic:setProtectedProperty' and 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty' both resolve to the same shorthand action ID for the 'config_test' entity type"); -+ $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() -+ */ -+ public function testParentAttributes(): void { -+ $definitions = $this->container->get('plugin.manager.config_action')->getDefinitions(); -+ // The \Drupal\config_test\Entity\ConfigQueryTest::concatProtectedProperty() -+ // does not have an attribute but the parent does so this is discovered. -+ $this->assertArrayHasKey('entity_method:config_test.query:concatProtectedProperty', $definitions); -+ } -+ -+ /** -+ * @see \Drupal\Core\Config\Action\ConfigActionManager -+ */ -+ public function testMissingAction(): void { -+ $this->expectException(PluginNotFoundException::class); -+ $this->expectErrorMessageMatches('/^The "does_not_exist" plugin does not exist/'); -+ $this->container->get('plugin.manager.config_action')->applyAction('does_not_exist', 'config_test.system', ['foo' => 'Yay!']); -+ } -+ -+} -diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php -new file mode 100644 -index 00000000..faf87866 ---- /dev/null -+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php -@@ -0,0 +1,58 @@ -+<?php -+ -+namespace Drupal\KernelTests\Core\Recipe; -+ -+use Drupal\Core\Recipe\RecipeDiscovery; -+use Drupal\Core\Recipe\UnknownRecipeException; -+use Drupal\KernelTests\KernelTestBase; -+ -+/** -+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeDiscovery -+ * @group Recipe -+ */ -+class RecipeDiscoveryTest extends KernelTestBase { -+ -+ public function providerRecipeDiscovery(): array { -+ return [ -+ ['install_two_modules', 'Install two modules'], -+ ['recipe_include', 'Recipe include'], -+ ]; -+ } -+ -+ /** -+ * Tests that recipe discovery can find recipes. -+ * -+ * @dataProvider providerRecipeDiscovery -+ */ -+ public function testRecipeDiscovery(string $recipe, string $name): void { -+ $discovery = new RecipeDiscovery(['core/tests/fixtures/recipes']); -+ $recipe = $discovery->getRecipe($recipe); -+ $this->assertSame($name, $recipe->name); -+ } -+ -+ public function providerRecipeDiscoveryException(): array { -+ return [ -+ 'missing recipe.yml' => ['no_recipe'], -+ 'no folder' => ['does_not_exist'], -+ ]; -+ } -+ -+ /** -+ * Tests the exception thrown when recipe discovery cannot find a recipe. -+ * -+ * @dataProvider providerRecipeDiscoveryException -+ */ -+ public function testRecipeDiscoveryException(string $recipe): void { -+ $discovery = new RecipeDiscovery(['core/tests/fixtures/recipes']); -+ try { -+ $discovery->getRecipe($recipe); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (UnknownRecipeException $e) { -+ $this->assertSame($recipe, $e->recipe); -+ $this->assertSame(['core/tests/fixtures/recipes'], $e->searchPaths); -+ $this->assertSame('Can not find the ' . $recipe . ' recipe, search paths: core/tests/fixtures/recipes', $e->getMessage()); -+ } -+ } -+ -+} -diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php -new file mode 100644 -index 00000000..b61ef6ee ---- /dev/null -+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php -@@ -0,0 +1,209 @@ -+<?php -+ -+namespace Drupal\KernelTests\Core\Recipe; -+ -+use Drupal\Core\Recipe\Recipe; -+use Drupal\Core\Recipe\RecipePreExistingConfigException; -+use Drupal\Core\Recipe\RecipeRunner; -+use Drupal\Core\Recipe\RecipeUnmetDependenciesException; -+use Drupal\KernelTests\KernelTestBase; -+use Drupal\node\Entity\NodeType; -+use Drupal\views\Entity\View; -+ -+/** -+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeRunner -+ * @group Recipe -+ */ -+class RecipeRunnerTest extends KernelTestBase { -+ -+ public function testModuleInstall(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertFalse($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is not installed'); -+ $this->assertFalse($this->container->get('module_handler')->moduleExists('text'), 'The text module is not installed'); -+ $this->assertFalse($this->container->get('module_handler')->moduleExists('node'), 'The node module is not installed'); -+ $this->assertFalse($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration does not exist'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_two_modules'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after applying the recipe. -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is installed'); -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('text'), 'The text module is installed'); -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed'); -+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); -+ $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE'); -+ } -+ -+ public function testModuleAndThemeInstall(): void { -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/base_theme_and_views'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after applying the recipe. -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('views'), 'The views module is installed'); -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed'); -+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_basetheme'), 'The test_basetheme theme is installed'); -+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subtheme'), 'The test_subtheme theme is installed'); -+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subsubtheme'), 'The test_subsubtheme theme is installed'); -+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); -+ $this->assertFalse($this->container->get('config.storage')->exists('views.view.archive'), 'The views.view.archive configuration has not been created'); -+ $this->assertEmpty(View::loadMultiple(), "No views exist"); -+ } -+ -+ public function testThemeModuleDependenciesInstall(): void { -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/theme_with_module_dependencies'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after applying the recipe. -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('test_module_required_by_theme'), 'The test_module_required_by_theme module is installed'); -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('test_another_module_required_by_theme'), 'The test_another_module_required_by_theme module is installed'); -+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'The test_theme_depending_on_modules theme is installed'); -+ } -+ -+ public function testModuleConfigurationOverride(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after applying the recipe. -+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); -+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); -+ $this->assertTrue($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to TRUE'); -+ $this->assertSame('Test content type', NodeType::load('test')->label()); -+ $node_type_data = $this->config('node.type.test')->get(); -+ $this->assertGreaterThan(0, strlen($node_type_data['uuid']), 'The node type configuration has been assigned a UUID.'); -+ // cSpell:disable-next-line -+ $this->assertSame('965SCwSA3qgVf47x7hEE4dufnUDpxKMsUfsqFtqjGn0', $node_type_data['_core']['default_config_hash']); -+ } -+ -+ public function testUnmetConfigurationDependencies(): void { -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/unmet_config_dependencies'); -+ try { -+ RecipeRunner::processRecipe($recipe); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (RecipeUnmetDependenciesException $e) { -+ $this->assertSame("The configuration 'node.type.test' has unmet dependencies", $e->getMessage()); -+ $this->assertSame('node.type.test', $e->configName); -+ } -+ } -+ -+ public function testApplySameRecipe(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state prior to applying the recipe. -+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('node.'), 'There is node configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ RecipeRunner::processRecipe($recipe); -+ $this->assertTrue(TRUE, 'Applying a recipe for the second time with no config changes results in a successful application'); -+ -+ $type = NodeType::load('test'); -+ $type->setNewRevision(FALSE); -+ $type->save(); -+ -+ $this->expectException(RecipePreExistingConfigException::class); -+ $this->expectExceptionMessage("The configuration 'node.type.test' exists already and does not match the recipe's configuration"); -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ } -+ -+ public function testConfigFromModule(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); -+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); -+ $this->assertSame(['dotted.default', 'override'], array_keys($config_test_entities)); -+ } -+ -+ public function testConfigWildcard(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_wildcard'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); -+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); -+ $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities)); -+ $this->assertSame('Default', $config_test_entities['dotted.default']->label()); -+ $this->assertSame('herp', $this->config('config_test.system')->get('404')); -+ } -+ -+ public function testConfigFromModuleAndRecipe(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module_and_recipe'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); -+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); -+ $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities)); -+ $this->assertSame('Provided by recipe', $config_test_entities['dotted.default']->label()); -+ $this->assertSame('derp', $this->config('config_test.system')->get('404')); -+ } -+ -+ public function testRecipeInclude(): void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); -+ $this->assertFalse($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module not installed'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed'); -+ $this->assertSame('Test content type', NodeType::load('test')->label()); -+ $this->assertSame('Another test content type', NodeType::load('another_test')->label()); -+ } -+ -+ public function testConfigActions() :void { -+ // Test the state prior to applying the recipe. -+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ $storage = \Drupal::entityTypeManager()->getStorage('config_test'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('recipe'); -+ $this->assertSame('Created by recipe', $config_test_entity->label()); -+ $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty()); -+ $this->assertSame('not bar', $this->config('config_test.system')->get('foo')); -+ } -+ -+ public function testConfigActionsPreExistingConfig() :void { -+ $this->enableModules(['config_test']); -+ $this->installConfig(['config_test']); -+ $this->assertSame('bar', $this->config('config_test.system')->get('foo')); -+ $storage = \Drupal::entityTypeManager()->getStorage('config_test'); -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->create(['id' => 'recipe', 'label' => 'Created by test']); -+ $config_test_entity->setProtectedProperty('Set by test'); -+ $config_test_entity->save(); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions'); -+ RecipeRunner::processRecipe($recipe); -+ -+ // Test the state after to applying the recipe. -+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ -+ $config_test_entity = $storage->load('recipe'); -+ $this->assertSame('Created by test', $config_test_entity->label()); -+ $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty()); -+ $this->assertSame('not bar', $this->config('config_test.system')->get('foo')); -+ } -+ -+} -diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php -new file mode 100644 -index 00000000..56a52fb8 ---- /dev/null -+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php -@@ -0,0 +1,108 @@ -+<?php -+ -+namespace Drupal\KernelTests\Core\Recipe; -+ -+use Drupal\Core\Recipe\Recipe; -+use Drupal\Core\Recipe\RecipeFileException; -+use Drupal\Core\Recipe\RecipeMissingExtensionsException; -+use Drupal\Core\Recipe\RecipePreExistingConfigException; -+use Drupal\Core\Recipe\UnknownRecipeException; -+use Drupal\KernelTests\KernelTestBase; -+ -+/** -+ * @coversDefaultClass \Drupal\Core\Recipe\Recipe -+ * @group Recipe -+ */ -+class RecipeTest extends KernelTestBase { -+ -+ /** -+ * {@inheritdoc} -+ */ -+ protected static $modules = ['system', 'user', 'field']; -+ -+ public function providerTestCreateFromDirectory(): array { -+ return [ -+ 'no extensions' => ['no_extensions', 'No extensions' , 'Testing', [], 'A recipe description'], -+ // Filter is installed because it is a dependency and it is not yet installed. -+ 'install_two_modules' => ['install_two_modules', 'Install two modules' , 'Content type', ['filter', 'text', 'node'], ''], -+ ]; -+ } -+ -+ /** -+ * @dataProvider providerTestCreateFromDirectory -+ */ -+ public function testCreateFromDirectory2(string $recipe_name, string $expected_name, string $expected_type, array $expected_modules, string $expected_description): void { -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/' . $recipe_name); -+ $this->assertSame($expected_name, $recipe->name); -+ $this->assertSame($expected_type, $recipe->type); -+ $this->assertSame($expected_modules, $recipe->install->modules); -+ $this->assertSame($expected_description, $recipe->description); -+ } -+ -+ public function testCreateFromDirectoryNoRecipe(): void { -+ $this->expectException(RecipeFileException::class); -+ $this->expectExceptionMessage('There is no core/tests/fixtures/recipes/no_recipe/recipe.yml file'); -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/no_recipe'); -+ } -+ -+ public function testCreateFromDirectoryNoRecipeName(): void { -+ $this->expectException(RecipeFileException::class); -+ $this->expectExceptionMessage('The core/tests/fixtures/recipes/no_name/recipe.yml has no name value.'); -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/no_name'); -+ } -+ -+ public function testCreateFromDirectoryMissingExtensions(): void { -+ $this->enableModules(['module_test']); -+ -+ // Create a missing fake dependency. -+ // dblog will depend on Config, which depends on a non-existing module Foo. -+ // Nothing should be installed. -+ \Drupal::state()->set('module_test.dependency', 'missing dependency'); -+ -+ try { -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/missing_extensions'); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (RecipeMissingExtensionsException $e) { -+ $this->assertSame(['does_not_exist_one', 'does_not_exist_two', 'foo'], $e->extensions); -+ } -+ } -+ -+ public function testPreExistingDifferentConfiguration(): void { -+ // Install the node module, its dependencies and configuration. -+ $this->container->get('module_installer')->install(['node']); -+ $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE'); -+ -+ try { -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ $this->fail('Expected exception not thrown'); -+ } -+ catch (RecipePreExistingConfigException $e) { -+ $this->assertSame("The configuration 'node.settings' exists already and does not match the recipe's configuration", $e->getMessage()); -+ $this->assertSame('node.settings', $e->configName); -+ } -+ } -+ -+ public function testPreExistingMatchingConfiguration(): void { -+ // Install the node module, its dependencies and configuration. -+ $this->container->get('module_installer')->install(['node']); -+ // Change the config to match the recipe's config to prevent the exception -+ // being thrown. -+ $this->config('node.settings')->set('use_admin_theme', TRUE)->save(); -+ -+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); -+ $this->assertSame('core/tests/fixtures/recipes/install_node_with_config/config', $recipe->config->recipeConfigDirectory); -+ } -+ -+ public function testRecipeIncludeMissing(): void { -+ try { -+ Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include_missing'); -+ } -+ catch (UnknownRecipeException $e) { -+ $this->assertSame('does_not_exist', $e->recipe); -+ $this->assertSame(['core/tests/fixtures/recipes'], $e->searchPaths); -+ $this->assertSame('Can not find the does_not_exist recipe, search paths: core/tests/fixtures/recipes', $e->getMessage()); -+ } -+ } -+ -+} -diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php -new file mode 100644 -index 00000000..14de011b ---- /dev/null -+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php -@@ -0,0 +1,324 @@ -+<?php -+ -+declare(strict_types=1); -+ -+namespace Drupal\Tests\Core\Recipe; -+ -+use Drupal\Core\Config\MemoryStorage; -+use Drupal\Core\Config\NullStorage; -+use Drupal\Core\Config\StorageInterface; -+use Drupal\Core\Recipe\RecipeConfigStorageWrapper; -+use Drupal\Tests\UnitTestCase; -+ -+/** -+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeConfigStorageWrapper -+ * @group Recipe -+ */ -+class RecipeConfigStorageWrapperTest extends UnitTestCase { -+ -+ /** -+ * Validate that an empty set of storage backends returns null storage. -+ */ -+ public function testNullStorage(): void { -+ $this->assertInstanceOf( -+ NullStorage::class, -+ RecipeConfigStorageWrapper::createStorageFromArray([]) -+ ); -+ } -+ -+ /** -+ * Validate that a single storage returns exactly the same instance. -+ */ -+ public function testSingleStorage(): void { -+ $storages = [new NullStorage()]; -+ $this->assertSame( -+ $storages[0], -+ RecipeConfigStorageWrapper::createStorageFromArray($storages) -+ ); -+ } -+ -+ /** -+ * Validate that multiple storages return underlying values correctly. -+ */ -+ public function testMultipleStorages(): void { -+ $a = new MemoryStorage(); -+ $a->write('a_key', ['a_data_first']); -+ $b = new MemoryStorage(); -+ -+ // Add a conflicting key so that we can test the first value is returned. -+ $b->write('a_key', ['a_data_second']); -+ $b->write('b_key', ['b_data']); -+ -+ // We test with a third storage as well since only two storages can be done -+ // via the constructor alone. -+ $c = new MemoryStorage(); -+ $c->write('c_key', ['c_data']); -+ -+ $storages = [$a, $b, $c]; -+ $wrapped = RecipeConfigStorageWrapper::createStorageFromArray($storages); -+ -+ $this->assertSame($a->read('a_key'), $wrapped->read('a_key')); -+ $this->assertNotEquals($b->read('a_key'), $wrapped->read('a_key')); -+ $this->assertSame($b->read('b_key'), $wrapped->read('b_key')); -+ $this->assertSame($c->read('c_key'), $wrapped->read('c_key')); -+ } -+ -+ /** -+ * Validate that the first storage checks existence first. -+ */ -+ public function testLeftSideExists(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('exists')->with('a_key') -+ ->willReturn(TRUE); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->never())->method('exists'); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertTrue($storage->exists('a_key')); -+ } -+ -+ /** -+ * Validate that we fall back to the second storage. -+ */ -+ public function testRightSideExists(): void { -+ [$a, $b] = $this->generateStorages(TRUE); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $storage->exists('a_key'); -+ } -+ -+ /** -+ * Validate FALSE when neither storage contains a key. -+ */ -+ public function testNotExists(): void { -+ [$a, $b] = $this->generateStorages(FALSE); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertFalse($storage->exists('a_key')); -+ } -+ -+ /** -+ * Validate that we read from storage A first. -+ */ -+ public function testReadFromA(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $value = ['a_value']; -+ $a->expects($this->once())->method('read')->with('a_key') -+ ->willReturn($value); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->never())->method('read'); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertSame($value, $storage->read('a_key')); -+ } -+ -+ /** -+ * Validate that we read from storage B second. -+ */ -+ public function testReadFromB(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('read')->with('a_key') -+ ->willReturn(FALSE); -+ $b = $this->createMock(StorageInterface::class); -+ $value = ['a_value']; -+ $b->expects($this->once())->method('read')->with('a_key') -+ ->willReturn($value); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertSame($value, $storage->read('a_key')); -+ } -+ -+ /** -+ * Validate when neither storage can read a value. -+ */ -+ public function testReadFails(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('read')->with('a_key') -+ ->willReturn(FALSE); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->once())->method('read')->with('a_key') -+ ->willReturn(FALSE); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertFalse($storage->read('a_key')); -+ } -+ -+ /** -+ * Test reading multiple values. -+ */ -+ public function testReadMultiple(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) -+ ->willReturn(['a_key' => ['a_value']]); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) -+ ->willReturn(['b_key' => ['b_value']]); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertEquals([ -+ 'a_key' => ['a_value'], -+ 'b_key' => ['b_value'], -+ ], $storage->readMultiple(['a_key', 'b_key'])); -+ } -+ -+ /** -+ * Test that storage A has precedence over storage B. -+ */ -+ public function testReadMultipleStorageA(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) -+ ->willReturn(['a_key' => ['a_value']]); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) -+ ->willReturn(['a_key' => ['a_conflicting_value'], 'b_key' => ['b_value']]); -+ -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertEquals([ -+ 'a_key' => ['a_value'], -+ 'b_key' => ['b_value'], -+ ], $storage->readMultiple(['a_key', 'b_key'])); -+ } -+ -+ /** -+ * Test methods that are unsupported. -+ * -+ * @param string $method -+ * The method to call. -+ * @param array $args -+ * The arguments to pass to the method. -+ * -+ * @dataProvider unsupportedMethods -+ */ -+ public function testUnsupportedMethods(string $method, ...$args): void { -+ $this->expectException(\BadMethodCallException::class); -+ $storage = new RecipeConfigStorageWrapper(new NullStorage(), new NullStorage()); -+ $storage->{$method}(...$args); -+ } -+ -+ /** -+ * Test that we only use storage A's encode method. -+ */ -+ public function testEncode(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $data = 'value'; -+ $a->expects($this->once())->method('encode')->with([$data]) -+ ->willReturn($data); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->never())->method('encode'); -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertSame($data, $storage->encode([$data])); -+ } -+ -+ /** -+ * Test that we only use storage A's decode method. -+ */ -+ public function testDecode(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $raw = 'value'; -+ $a->expects($this->once())->method('decode')->with($raw) -+ ->willReturn([$raw]); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->never())->method('decode'); -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertEquals([$raw], $storage->decode($raw)); -+ } -+ -+ /** -+ * Test that list all merges values and makes them unique. -+ */ -+ public function testListAll(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->method('listAll')->with('node.') -+ ->willReturn(['node.type']); -+ $b = $this->createMock(StorageInterface::class); -+ $b->method('listAll')->with('node.') -+ ->willReturn(['node.type', 'node.id']); -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertEquals([ -+ 0 => 'node.type', -+ 2 => 'node.id', -+ ], $storage->listAll('node.')); -+ } -+ -+ /** -+ * Test creating a collection passes the name through to the child storages. -+ */ -+ public function testCreateCollection(): void { -+ $collection_name = 'collection'; -+ $a = $this->createMock(StorageInterface::class); -+ $b = $this->createMock(StorageInterface::class); -+ /** @var \PHPUnit\Framework\MockObject\MockObject $mock */ -+ foreach ([$a, $b] as $mock) { -+ $mock->expects($this->once())->method('createCollection') -+ ->with($collection_name)->willReturn(new NullStorage($collection_name)); -+ } -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $new = $storage->createCollection($collection_name); -+ $this->assertInstanceOf(RecipeConfigStorageWrapper::class, $new); -+ $this->assertEquals($collection_name, $new->getCollectionName()); -+ $this->assertNotEquals($storage, $new); -+ } -+ -+ /** -+ * Test that we merge and return only unique collection names. -+ */ -+ public function testGetAllCollectionNames(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('getAllCollectionNames') -+ ->willReturn(['collection_1', 'collection_2']); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->once())->method('getAllCollectionNames') -+ ->willReturn(['collection_3', 'collection_1', 'collection_2']); -+ $storage = new RecipeConfigStorageWrapper($a, $b); -+ $this->assertEquals([ -+ 'collection_1', -+ 'collection_2', -+ 'collection_3', -+ ], $storage->getAllCollectionNames()); -+ } -+ -+ /** -+ * Test the collection name is stored properly. -+ */ -+ public function testGetCollection(): void { -+ $a = $this->createMock(StorageInterface::class); -+ $b = $this->createMock(StorageInterface::class); -+ $storage = new RecipeConfigStorageWrapper($a, $b, 'collection'); -+ $this->assertEquals('collection', $storage->getCollectionName()); -+ } -+ -+ /** -+ * Generate two storages where the second storage should return a value. -+ * -+ * @param bool $b_return -+ * The return value for storage $b's exist method. -+ * -+ * @return \Drupal\Core\Config\StorageInterface[] -+ * An array of two mocked storages. -+ */ -+ private function generateStorages(bool $b_return): array { -+ $a = $this->createMock(StorageInterface::class); -+ $a->expects($this->once())->method('exists')->with('a_key') -+ ->willReturn(FALSE); -+ $b = $this->createMock(StorageInterface::class); -+ $b->expects($this->once())->method('exists')->with('a_key') -+ ->willReturn($b_return); -+ return [$a, $b]; -+ } -+ -+ /** -+ * Data provider for methods that are unsupported. -+ * -+ * @return array -+ * An array of method names w/ args to test the unsupported methods. -+ */ -+ private function unsupportedMethods(): array { -+ return [ -+ ['write', 'name', []], -+ ['delete', 'name'], -+ ['rename', 'old_name', 'new_name'], -+ ['deleteAll'], -+ ]; -+ } -+ -+} -diff --git a/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml -new file mode 100644 -index 00000000..6d9589f9 ---- /dev/null -+++ b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml -@@ -0,0 +1,6 @@ -+name: 'Base theme and views' -+type: 'Testing' -+install: -+ - test_subsubtheme -+ - node -+ - views -diff --git a/core/tests/fixtures/recipes/config_actions/recipe.yml b/core/tests/fixtures/recipes/config_actions/recipe.yml -new file mode 100644 -index 00000000..4e16eeb6 ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_actions/recipe.yml -@@ -0,0 +1,13 @@ -+name: 'Config actions' -+type: 'Testing' -+install: -+ - config_test -+config: -+ actions: -+ config_test.dynamic.recipe: -+ ensure_exists: -+ label: 'Created by recipe' -+ setProtectedProperty: 'Set by recipe' -+ config_test.system: -+ simple_config_update: -+ foo: 'not bar' -diff --git a/core/tests/fixtures/recipes/config_from_module/recipe.yml b/core/tests/fixtures/recipes/config_from_module/recipe.yml -new file mode 100644 -index 00000000..f88aa486 ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_from_module/recipe.yml -@@ -0,0 +1,9 @@ -+name: 'Config from module' -+type: 'Testing' -+install: -+ - config_test -+config: -+ import: -+ config_test: -+ - config_test.dynamic.dotted.default -+ - config_test.dynamic.override -diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml -new file mode 100644 -index 00000000..ce5eb672 ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml -@@ -0,0 +1,6 @@ -+id: dotted.default -+label: 'Provided by recipe' -+weight: 0 -+protected_property: Default -+# Intentionally commented out to verify default status behavior. -+# status: 1 -diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml -new file mode 100644 -index 00000000..07a32843 ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml -@@ -0,0 +1,2 @@ -+foo: bar -+404: derp -diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml -new file mode 100644 -index 00000000..91b42de4 ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml -@@ -0,0 +1,12 @@ -+name: 'Config from module and recipe' -+type: 'Testing' -+install: -+ - config_test -+ - tour -+ - tour_test -+config: -+ import: -+ config_test: '*' -+ tour_test: -+ - tour.tour.tour-test -+ - tour.tour.tour-test2 -diff --git a/core/tests/fixtures/recipes/config_wildcard/recipe.yml b/core/tests/fixtures/recipes/config_wildcard/recipe.yml -new file mode 100644 -index 00000000..582f7aeb ---- /dev/null -+++ b/core/tests/fixtures/recipes/config_wildcard/recipe.yml -@@ -0,0 +1,8 @@ -+name: 'Config wildcard' -+type: 'Testing' -+install: -+ - config_test -+ - tour -+config: -+ import: -+ config_test: '*' -diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml -new file mode 100644 -index 00000000..6cb95cbc ---- /dev/null -+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml -@@ -0,0 +1 @@ -+use_admin_theme: true -diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml -new file mode 100644 -index 00000000..567eb1bf ---- /dev/null -+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml -@@ -0,0 +1,9 @@ -+langcode: en -+status: true -+name: 'Test content type' -+type: test -+description: 'Test content type from a recipe' -+help: '' -+new_revision: true -+preview_mode: 1 -+display_submitted: true -diff --git a/core/tests/fixtures/recipes/install_node_with_config/recipe.yml b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml -new file mode 100644 -index 00000000..e2fc243b ---- /dev/null -+++ b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml -@@ -0,0 +1,5 @@ -+name: 'Install node with config' -+type: 'Content type' -+install: -+ - node -+ - drupal:text -diff --git a/core/tests/fixtures/recipes/install_two_modules/recipe.yml b/core/tests/fixtures/recipes/install_two_modules/recipe.yml -new file mode 100644 -index 00000000..ee57ca14 ---- /dev/null -+++ b/core/tests/fixtures/recipes/install_two_modules/recipe.yml -@@ -0,0 +1,5 @@ -+name: 'Install two modules' -+type: 'Content type' -+install: -+ - node -+ - text -diff --git a/core/tests/fixtures/recipes/missing_extensions/recipe.yml b/core/tests/fixtures/recipes/missing_extensions/recipe.yml -new file mode 100644 -index 00000000..77c82fbe ---- /dev/null -+++ b/core/tests/fixtures/recipes/missing_extensions/recipe.yml -@@ -0,0 +1,6 @@ -+name: 'Missing extensions' -+type: 'Testing' -+install: -+ - does_not_exist_one -+ - does_not_exist_two -+ - dblog -diff --git a/core/tests/fixtures/recipes/no_extensions/recipe.yml b/core/tests/fixtures/recipes/no_extensions/recipe.yml -new file mode 100644 -index 00000000..b7d3aeb4 ---- /dev/null -+++ b/core/tests/fixtures/recipes/no_extensions/recipe.yml -@@ -0,0 +1,3 @@ -+name: 'No extensions' -+description: 'A recipe description' -+type: 'Testing' -diff --git a/core/tests/fixtures/recipes/no_name/recipe.yml b/core/tests/fixtures/recipes/no_name/recipe.yml -new file mode 100644 -index 00000000..65daa60d ---- /dev/null -+++ b/core/tests/fixtures/recipes/no_name/recipe.yml -@@ -0,0 +1 @@ -+type: 'Testing' -diff --git a/core/tests/fixtures/recipes/no_recipe/.gitkeep b/core/tests/fixtures/recipes/no_recipe/.gitkeep -new file mode 100644 -index 00000000..e69de29b -diff --git a/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml -new file mode 100644 -index 00000000..48a9258b ---- /dev/null -+++ b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml -@@ -0,0 +1,9 @@ -+langcode: en -+status: true -+name: 'Another test content type' -+type: another_test -+description: 'Another test content type from a recipe' -+help: '' -+new_revision: true -+preview_mode: 1 -+display_submitted: true -diff --git a/core/tests/fixtures/recipes/recipe_include/recipe.yml b/core/tests/fixtures/recipes/recipe_include/recipe.yml -new file mode 100644 -index 00000000..a81aa075 ---- /dev/null -+++ b/core/tests/fixtures/recipes/recipe_include/recipe.yml -@@ -0,0 +1,6 @@ -+name: 'Recipe include' -+type: 'Testing' -+recipes: -+ - install_node_with_config -+install: -+ - dblog -diff --git a/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml b/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml -new file mode 100644 -index 00000000..b80db734 ---- /dev/null -+++ b/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml -@@ -0,0 +1,7 @@ -+name: 'Recipe include' -+type: 'Testing' -+recipes: -+ - recipe_include -+ - does_not_exist -+install: -+ - config_test -diff --git a/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml -new file mode 100644 -index 00000000..550c3610 ---- /dev/null -+++ b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml -@@ -0,0 +1,5 @@ -+name: 'Theme with module dependencies' -+type: 'Testing' -+install: -+ - test_theme_depending_on_modules -+ - test_module_required_by_theme -diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml -new file mode 100644 -index 00000000..567eb1bf ---- /dev/null -+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml -@@ -0,0 +1,9 @@ -+langcode: en -+status: true -+name: 'Test content type' -+type: test -+description: 'Test content type from a recipe' -+help: '' -+new_revision: true -+preview_mode: 1 -+display_submitted: true -diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml -new file mode 100644 -index 00000000..e90fca91 ---- /dev/null -+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml -@@ -0,0 +1,2 @@ -+name: 'Unmet config dependencies' -+type: 'Testing'