diff --git a/docs/drupal-dependencies.md b/docs/drupal-dependencies.md new file mode 100644 index 0000000000..9dc8aaf191 --- /dev/null +++ b/docs/drupal-dependencies.md @@ -0,0 +1,35 @@ +Inspecting Drupal dependencies +============================== +:octicons-tag-24: 13.4+ + +These commands allow a developer or site builder to inspect the Drupal dependencies. It's similar with Composer's `why` command but acts in the Drupal realm, by showing dependencies between modules or config entities. + +Find module dependants +---------------------- + +Drupal modules are able to define other modules as dependencies, using the module's [metadata info.yml file](https://www.drupal.org/docs/develop/creating-modules/let-drupal-know-about-your-module-with-an-infoyml-file). To get all modules that depend on a given module type: + + drush why:module node --type=module + +This will show all the _installed_ module dependents of `node` module. The results are rendered visually as a tree, making it easy to understand the nested relations. It also marks visually the circular dependencies. + +If you want to get the dependency tree as data, use the `--format` option. E.g., `--format=yaml` or `--format=json`. + +The above command only rendered the dependency tree of _installed_ modules. If you need to get the module dependants regardless whether they are installed or not, use the `--no-only-installed` option/flag: + + drush why:module node --type=module --no-only-installed + +Config entities are able to declare [dependencies on modules](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). You can find also the config entities that depend on a given module. The following command shows all config entities depending on `node` module: + + drush why:module node --type=config + +Dependents are also rendered as a tree, showing a nested structure. The `--format` option can be used in the same way, to get a machine-readable structure. + +Find config entity dependants +----------------------------- + +Config entities are able also to declare [dependencies on other config entities](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). With `why:config` Drush command we can determine the config entities depending on a specific entity: + + drush why:config node.type.article + +This will also render the results in a structured tree visualisation. Same, the `--format` option can be used to get data structured as `json`, `yaml`, etc. diff --git a/src/Commands/core/DocsCommands.php b/src/Commands/core/DocsCommands.php index fbc244fb51..b2b75e17f0 100644 --- a/src/Commands/core/DocsCommands.php +++ b/src/Commands/core/DocsCommands.php @@ -31,6 +31,7 @@ final class DocsCommands extends DrushCommands const EXAMPLE_SYNC_VIA_HTTP = 'docs:example-sync-via-http'; const POLICY = 'docs:policy'; const DEPLOY = 'docs:deploy'; + const DRUPAL_DEPENDENCIES = 'docs:drupal-dependencies'; /** * README.md @@ -218,4 +219,15 @@ public function deploy(): void { self::printFileTopic($this->commandData); } + + /** + * Inspecting Drupal dependencies. + */ + #[CLI\Command(name: self::DRUPAL_DEPENDENCIES)] + #[CLI\Help(hidden: true)] + #[CLI\Topics(path: '../../../docs/drupal-dependencies.md')] + public function drupalDependencies(): void + { + self::printFileTopic($this->commandData); + } } diff --git a/src/Commands/core/DrupalDependenciesCommands.php b/src/Commands/core/DrupalDependenciesCommands.php new file mode 100644 index 0000000000..2f44b1f571 --- /dev/null +++ b/src/Commands/core/DrupalDependenciesCommands.php @@ -0,0 +1,329 @@ + dependent paths. Used to detect circular references. + */ + private array $relation = []; + + /** + * Computed dependent -> dependencies relations. + */ + private array $dependencies = [ + // List of module dependencies grouped by module dependent. + 'module-module' => [], + // List of config module dependencies grouped by config dependent. + 'config-module' => [], + // List of config dependencies grouped by config dependent. + 'config-config' => [], + ]; + + public function __construct( + private readonly ModuleExtensionList $moduleExtensionList, + private readonly array $installedModules, + ) { + parent::__construct(); + } + + public static function create(ContainerInterface $container): self + { + return new self( + $container->get('extension.list.module'), + $container->getParameter('container.modules') + ); + } + + #[CLI\Command(name: self::WHY_MODULE, aliases: ['wm'])] + #[CLI\Help(description: 'List all objects (modules, configurations) depending on a given module')] + #[CLI\Argument(name: 'module', description: 'The module to check dependents for')] + #[CLI\Option( + name: 'type', + description: 'Type of dependents: module, config', + suggestedValues: ['module', 'config'] + )] + #[CLI\Option( + name: 'only-installed', + description: 'Only check for installed modules' + )] + #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] + #[CLI\Usage( + name: 'drush why:module node --type=module', + description: 'Show all installed modules depending on node module' + )] + #[CLI\Usage( + name: 'drush why:module node --type=module --no-only-installed', + description: 'Show all modules, including uninstalled, depending on node module' + )] + #[CLI\Usage( + name: 'drush why:module node --type=config', + description: 'Show all configuration entities depending on node module' + )] + #[CLI\Usage( + name: 'drush why:module node --type=config --format=json', + description: 'Return config entity dependents as JSON' + )] + #[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])] + public function dependentsOfModule(string $module, array $options = [ + 'type' => InputOption::VALUE_REQUIRED, + 'only-installed' => true, + 'format' => '', + ]): string|UnstructuredData|null + { + if ($options['type'] === 'module') { + $this->buildDependents($this->dependencies['module-module']); + } else { + $this->scanConfigs(); + $this + ->buildDependents($this->dependencies['config-module']) + ->buildDependents($this->dependencies['config-config']); + } + + if (!isset($this->dependents[$module])) { + $this->logger()->notice(dt('No @type depends on @module', [ + '@module' => $module, + '@type' => $options['type'] === 'module' ? dt('other module') : dt('config entity'), + ])); + return null; + } + + $this->buildTree($module); + + if (empty($options['format'])) { + return $this->drawTree($module); + } + + return new UnstructuredData($this->tree); + } + + #[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:module')] + public function validateDependentsOfModule(CommandData $commandData): void + { + $type = $commandData->input()->getOption('type'); + if (empty($type)) { + throw new \InvalidArgumentException("The --type option is mandatory"); + } + if (!in_array($type, ['module', 'config'], true)) { + throw new \InvalidArgumentException( + "The --type option can take only 'module' or 'config' as value" + ); + } + + $notOnlyInstalled = $commandData->input()->getOption('no-only-installed'); + if ($notOnlyInstalled && $type === 'config') { + throw new \InvalidArgumentException("Cannot use --type=config together with --no-only-installed"); + } + + $module = $commandData->input()->getArgument('module'); + if ($type === 'module') { + $this->dependencies['module-module'] = array_map(function (Extension $extension): array { + return array_map(function (string $dependencyString) { + return Dependency::createFromString($dependencyString)->getName(); + }, $extension->info['dependencies']); + }, $this->moduleExtensionList->reset()->getList()); + + if (!$notOnlyInstalled) { + $this->dependencies['module-module'] = array_intersect_key( + $this->dependencies['module-module'], + $this->installedModules, + ); + } + if (!isset($this->dependencies['module-module'][$module])) { + throw new \InvalidArgumentException(dt('Invalid @module module', [ + '@module' => $module, + ])); + } + } elseif (!isset($this->installedModules[$module])) { + throw new \InvalidArgumentException(dt('Invalid @module module', [ + '@module' => $module, + ])); + } + } + + #[CLI\Command(name: self::WHY_CONFIG, aliases: ['wc'])] + #[CLI\Help(description: 'List all config entities depending on a given config entity')] + #[CLI\Argument(name: 'config', description: 'The config entity to check dependents for')] + #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] + #[CLI\Usage( + name: 'drush why:config node.type.article', + description: 'Show all config entities modules depending on node.type.article' + )] + #[CLI\Usage( + name: 'drush why:config node.type.article --format=yaml', + description: 'Return config entity dependents as YAML' + )] + #[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])] + public function dependentsOfConfig(string $config, array $options = [ + 'format' => '', + ]): string|UnstructuredData|null + { + $this->scanConfigs(false); + $this->buildDependents($this->dependencies['config-config']); + + if (!isset($this->dependents[$config])) { + $this->logger()->notice(dt('No other config entity depends on @config', [ + '@config' => $config, + ])); + return null; + } + + $this->buildTree($config); + + if (empty($options['format'])) { + return $this->drawTree($config); + } + + return new UnstructuredData($this->tree); + } + + #[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:config')] + public function validateDependentsOfConfig(CommandData $commandData): void + { + $configName = $commandData->input()->getArgument('config'); + $configManager = \Drupal::getContainer()->get('config.manager'); + if (!$configManager->loadConfigEntityByName($configName)) { + throw new \InvalidArgumentException(dt('Invalid @config config entity', [ + '@config' => $configName, + ])); + } + } + + /** + * Builds the nested dependency tree. + */ + protected function buildTree(string $dependency, array $path = []): void + { + $path[] = $dependency; + foreach ($this->dependents[$dependency] as $dependent) { + if (isset($this->relation[$dependency]) && $this->relation[$dependency] === $dependent) { + // This relation has been already defined on other path. We mark + // it as circular reference. + NestedArray::setValue($this->tree, [ + ...$path, + ...[$dependent], + ], $dependent . ':' . self::CIRCULAR_REFERENCE); + continue; + } + + // Save this relation to avoid infinite circular references. + $this->relation[$dependency] = $dependent; + + if (isset($this->dependents[$dependent])) { + $this->buildTree($dependent, $path); + } else { + NestedArray::setValue($this->tree, [...$path, ...[$dependent]], $dependent); + } + } + } + + /** + * Build the reverse the relation: dependent -> dependencies. + */ + protected function buildDependents(array $dependenciesPerDependent): self + { + foreach ($dependenciesPerDependent as $dependent => $dependencies) { + foreach ($dependencies as $dependency) { + $this->dependents[$dependency][$dependent] = $dependent; + } + } + + // Make dependents order predictable. + foreach ($this->dependents as $dependency => $dependents) { + ksort($this->dependents[$dependency]); + } + ksort($this->dependents); + + return $this; + } + + /** + * Scans all config entities and store their module and config dependencies. + */ + protected function scanConfigs(bool $scanModuleDependencies = true): void + { + $entityTypeManager = \Drupal::entityTypeManager(); + $configTypeIds = array_keys( + array_filter( + $entityTypeManager->getDefinitions(), + fn(EntityTypeInterface $entityType): bool => $entityType->entityClassImplements(ConfigEntityInterface::class), + ) + ); + + foreach ($configTypeIds as $configTypeId) { + /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $config */ + foreach ($entityTypeManager->getStorage($configTypeId)->loadMultiple() as $config) { + $dependencies = $config->getDependencies(); + $name = $config->getConfigDependencyName(); + if ($scanModuleDependencies && !empty($dependencies['module'])) { + $this->dependencies['config-module'][$name] = $dependencies['module']; + } + if (!empty($dependencies['config'])) { + $this->dependencies['config-config'][$name] = $dependencies['config']; + } + } + } + } + + /** + * Draws a visual representation of the dependency tree. + */ + private function drawTree(string $dependency): string + { + $recursiveArrayIterator = new \RecursiveArrayIterator(current($this->tree)); + $recursiveTreeIterator = new \RecursiveTreeIterator( + $recursiveArrayIterator, + RecursiveIteratorIterator::SELF_FIRST, + ); + $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_HAS_NEXT, '├─'); + $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_LAST, '└─'); + $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_HAS_NEXT, '│ '); + $canvas = [$dependency]; + foreach ($recursiveTreeIterator as $row => $value) { + $key = $recursiveTreeIterator->getInnerIterator()->key(); + $current = $recursiveTreeIterator->getInnerIterator()->current(); + $label = $row; + if ($key . ':' . self::CIRCULAR_REFERENCE === $current) { + $label .= ' (' . dt('circular') . ')'; + } + $canvas[] = $label; + } + return implode(PHP_EOL, $canvas); + } +} diff --git a/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml b/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml new file mode 100644 index 0000000000..a4a352d05d --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml @@ -0,0 +1,11 @@ +name: Dependent 2 +type: module +description: Dependent 2 module +core_version_requirement: ^10.2 || ^11 +package: Other +dependencies: + - drupal:node + - drupal:history + - drupal:taxonomy + - drupal:comment + - drupal:options diff --git a/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml b/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml new file mode 100644 index 0000000000..4fc96185de --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml @@ -0,0 +1,8 @@ +name: Dependent 1 +type: module +description: Dependent 1 module +core_version_requirement: ^10.2 || ^11 +package: Other +dependencies: + - drupal:node + - dependent1:dependent1 diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml new file mode 100644 index 0000000000..f50de17ced --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml @@ -0,0 +1,46 @@ +dependencies: + config: + - field.field.node.vegetable.latin_name + - field.field.node.vegetable.vegetable_type + - node.type.vegetable + module: + - taxonomy +id: node.vegetable.default +targetEntityType: node +bundle: vegetable +mode: default +content: + title: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + latin_name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + vegetable_type: + type: options_select + weight: 5 + region: content + settings: { } + third_party_settings: { } + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } +hidden: + promote: true + status: true + sticky: true + uid: true + diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml new file mode 100644 index 0000000000..e69ce1aa80 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml @@ -0,0 +1,32 @@ +dependencies: + config: + - taxonomy.vocabulary.vegetable_type + module: + - text +id: taxonomy_term.vegetable_type.default +targetEntityType: taxonomy_term +bundle: vegetable_type +mode: default +content: + description: + type: text_textfield + weight: 0 + region: content + settings: { } + third_party_settings: { } + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } +hidden: { } diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml new file mode 100644 index 0000000000..7434b829c9 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml @@ -0,0 +1,31 @@ +dependencies: + config: + - field.field.node.vegetable.latin_name + - field.field.node.vegetable.vegetable_type + - node.type.vegetable + module: + - text + - user +id: node.vegetable.default +targetEntityType: node +bundle: vegetable +mode: default +content: + latin_name: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: 8 + region: content + vegetable_type: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: -1 + region: content +hidden: + links: true diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml new file mode 100644 index 0000000000..728cd66dc7 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml @@ -0,0 +1,15 @@ +dependencies: + config: + - field.storage.node.latin_name + - node.type.vegetable +id: node.vegetable.latin_name +field_name: latin_name +entity_type: node +bundle: vegetable +label: Latin name +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml new file mode 100644 index 0000000000..6082ce6740 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml @@ -0,0 +1,25 @@ +dependencies: + config: + - field.storage.node.vegetable_type + - node.type.vegetable + - taxonomy.vocabulary.vegetable_type +id: node.vegetable.vegetable_type +field_name: vegetable_type +entity_type: node +bundle: vegetable +label: Vegetable type +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + vegetable_type: vegetable_type + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml new file mode 100644 index 0000000000..9983f5ed24 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml @@ -0,0 +1,19 @@ +dependencies: + module: + - node + - taxonomy +id: node.latin_name +field_name: latin_name +entity_type: node +type: string +settings: + max_length: 255 + case_sensitive: true + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: false +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml new file mode 100644 index 0000000000..43f648a358 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml @@ -0,0 +1,17 @@ +dependencies: + module: + - node + - taxonomy +id: node.vegetable_type +field_name: vegetable_type +entity_type: node +type: entity_reference +settings: + target_type: taxonomy_term +module: comment +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml new file mode 100644 index 0000000000..fb7664466a --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml @@ -0,0 +1,2 @@ +name: Vegetable +type: vegetable diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml new file mode 100644 index 0000000000..1dbafe44d6 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml @@ -0,0 +1,2 @@ +name: Vegetable type +vid: vegetable_type diff --git a/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml b/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml new file mode 100644 index 0000000000..3a2e54eab6 --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml @@ -0,0 +1,7 @@ +name: Dependent 3 +type: module +description: Dependent 3 module +core_version_requirement: ^10.2 || ^11 +package: Other +dependencies: + - dependent2:dependent2 diff --git a/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml b/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml new file mode 100644 index 0000000000..a5bdd452ab --- /dev/null +++ b/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml @@ -0,0 +1,7 @@ +name: Dependent 4 +type: module +description: Dependent 4 module +core_version_requirement: ^10.2 || ^11 +package: Other +dependencies: + - dependent2:dependent2 diff --git a/tests/integration/DrupalDependenciesTest.php b/tests/integration/DrupalDependenciesTest.php new file mode 100644 index 0000000000..2e806b3641 --- /dev/null +++ b/tests/integration/DrupalDependenciesTest.php @@ -0,0 +1,267 @@ +drush('list'); + $this->assertStringContainsString('why:module (wm)', $this->getOutput()); + $this->assertStringContainsString('List all objects (modules, configurations)', $this->getOutput()); + $this->assertStringContainsString('depending on a given module', $this->getOutput()); + + // Trying to check an uninstalled module. + $this->drush('why:module', ['node'], ['type' => 'module'], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString('Invalid node module', $this->getErrorOutput()); + + // Check also uninstalled modules. + $this->drush('wm', ['node'], [ + 'type' => 'module', + 'no-only-installed' => null, + ]); + + // In Drupal 10, book, forum, statistics and tracker modules were part + // of Drupal core. Ensure a backwards compatible expectation. + // @todo Remove the BC layer when Drupal 10 support is dropped. + $expected = DeprecationHelper::backwardsCompatibleCall( + \Drupal::VERSION, + '11.0.0', + fn() => << <<assertSame($expected, $this->getOutput()); + + // Install node module. + $this->drush('pm:install', ['node']); + + // No installed dependencies. + $this->drush('why:module', ['node'], ['type' => 'module']); + $this->assertSame('[notice] No other module depends on node', $this->getErrorOutput()); + + $this->drush('pm:install', ['taxonomy']); + $this->drush('wm', ['node'], ['type' => 'module']); + $expected = <<assertSame($expected, $this->getOutput()); + + $this->drush('pm:install', ['dependent3']); + $this->drush('wm', ['node'], ['type' => 'module']); + $expected = <<assertSame($expected, $this->getOutput()); + + // Test result formatted as JSON. + $this->drush('wm', ['node'], [ + 'type' => 'module', + 'format' => 'json', + ]); + $expected = [ + 'node' => [ + 'dependent1' => [ + 'dependent2' => [ + 'dependent3' => 'dependent3', + ], + ], + 'dependent2' => [ + 'dependent3' => 'dependent3:***circular***', + ], + 'history' => [ + 'dependent1' => [ + 'dependent2' => 'dependent2:***circular***', + ], + ], + 'taxonomy' => [ + 'dependent1' => [ + 'dependent2' => 'dependent2:***circular***', + ], + ], + ], + ]; + $this->assertSame($expected, $this->getOutputFromJSON()); + } + + /** + * @covers ::validateDependentsOfModule + */ + public function testOptionsMismatch(): void + { + $this->drush('why:module', ['node'], [], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString("The --type option is mandatory", $this->getErrorOutput()); + + $this->drush('why:module', ['node'], ['type' => 'wrong'], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString( + "The --type option can take only 'module' or 'config' as value", + $this->getErrorOutput() + ); + + $this->drush('why:module', ['node'], [ + 'type' => 'config', + 'no-only-installed' => null, + ], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString( + "Cannot use --type=config together with --no-only-installed", + $this->getErrorOutput() + ); + } + + /** + * @covers ::dependentsOfModule + */ + public function testConfigDependentOfModule(): void + { + // Trying to check an uninstalled module. + $this->drush('why:module', ['node'], ['type' => 'config'], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString('Invalid node module', $this->getErrorOutput()); + + // Install node module. + $this->drush('pm:install', ['node']); + + // No installed dependencies. + $this->drush('why:module', ['node'], ['type' => 'config']); + $expected = <<assertSame($expected, $this->getOutput()); + + $this->drush('pm:install', ['dependent3']); + $this->drush('wm', ['node'], ['type' => 'config']); + $expected = <<assertSame($expected, $this->getOutput()); + } + + /** + * @covers ::dependentsOfConfig + */ + public function testConfigDependentOfConfig(): void + { + $this->drush('why:config', ['system.site'], [], UnishTestCase::EXIT_ERROR); + $this->assertStringContainsString('Invalid system.site config entity', $this->getErrorOutput()); + + // Install dependent3 module. + $this->drush('pm:install', ['dependent3']); + + $this->drush('why:config', ['node.type.vegetable']); + $expected = <<assertSame($expected, $this->getOutput()); + } + + protected function tearDown(): void + { + try { + $this->drush('pmu', ['node,history,taxonomy,comment,dependent3'], ['yes' => null]); + } catch (\Exception) { + // The modules were not installed. + } + parent::tearDown(); + } +}