From 74325fd86e269e986960b07d09a5b3ca6b6f9ace Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Mon, 29 Apr 2024 16:30:07 +0100
Subject: [PATCH] Issue #3427558 by srishtiiee, alexpott, phenaproxima,
 thejimbirch: Output information from the recipe application process

(cherry picked from commit 300b8e0fb982675b53f635715c4c06b9d96747be)
---
 core/lib/Drupal/Core/Recipe/RecipeCommand.php | 55 ++++++++++++++++++-
 core/lib/Drupal/Core/Recipe/RecipeRunner.php  |  5 ++
 .../Core/Recipe/RecipeCommandTest.php         |  6 +-
 phpstan-level9-baseline.neon                  |  2 +-
 4 files changed, 62 insertions(+), 6 deletions(-)

diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
index d0fa0706469f..9bfd555b950a 100644
--- a/core/lib/Drupal/Core/Recipe/RecipeCommand.php
+++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
@@ -4,6 +4,7 @@
 
 namespace Drupal\Core\Recipe;
 
+use Drupal\Component\Render\PlainTextOutput;
 use Drupal\Core\Config\Checkpoint\Checkpoint;
 use Drupal\Core\Config\ConfigImporter;
 use Drupal\Core\Config\ConfigImporterException;
@@ -83,8 +84,45 @@ protected function execute(InputInterface $input, OutputInterface $output): int
     $backup_checkpoint = $checkpoint_storage
       ->checkpoint("Backup before the '$recipe->name' recipe.");
     try {
-      RecipeRunner::processRecipe($recipe);
-      $io->success(sprintf('%s applied successfully', $recipe->name));
+      $steps = RecipeRunner::toBatchOperations($recipe);
+      $progress_bar = $io->createProgressBar();
+      $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n");
+      $progress_bar->setMessage($this->toPlainString(t('Applying recipe')));
+      $progress_bar->start(count($steps));
+
+      /** @var array{message?: \Stringable|string, results: array{module?: string[], theme?: string[], content?: string[], recipe?: string[]}} $context */
+      $context = ['results' => []];
+      foreach ($steps as $step) {
+        call_user_func_array($step[0], array_merge($step[1], [&$context]));
+        if (isset($context['message'])) {
+          $progress_bar->setMessage($this->toPlainString($context['message']));
+        }
+        unset($context['message']);
+        $progress_bar->advance();
+      }
+      if ($io->isVerbose()) {
+        if (!empty($context['results']['module'])) {
+          $io->section($this->toPlainString(t('Modules installed')));
+          $modules = array_map(fn ($module) => \Drupal::service('extension.list.module')->getName($module), $context['results']['module']);
+          sort($modules, SORT_NATURAL);
+          $io->listing($modules);
+        }
+        if (!empty($context['results']['theme'])) {
+          $io->section($this->toPlainString(t('Themes installed')));
+          $themes = array_map(fn ($theme) => \Drupal::service('extension.list.theme')->getName($theme), $context['results']['theme']);
+          sort($themes, SORT_NATURAL);
+          $io->listing($themes);
+        }
+        if (!empty($context['results']['content'])) {
+          $io->section($this->toPlainString(t('Content created for recipes')));
+          $io->listing($context['results']['content']);
+        }
+        if (!empty($context['results']['recipe'])) {
+          $io->section($this->toPlainString(t('Recipes applied')));
+          $io->listing($context['results']['recipe']);
+        }
+      }
+      $io->success($this->toPlainString(t('%recipe applied successfully', ['%recipe' => $recipe->name])));
       return 0;
     }
     catch (\Throwable $e) {
@@ -98,6 +136,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
     }
   }
 
+  /**
+   * Converts a stringable like TranslatableMarkup to a plain text string.
+   *
+   * @param \Stringable|string $text
+   *   The string to convert.
+   *
+   * @return string
+   *   The plain text string.
+   */
+  private function toPlainString(\Stringable|string $text): string {
+    return PlainTextOutput::renderFromHtml((string) $text);
+  }
+
   /**
    * Rolls config back to a particular checkpoint.
    *
diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
index 2a9a0b2caece..0e08d2df16bf 100644
--- a/core/lib/Drupal/Core/Recipe/RecipeRunner.php
+++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
@@ -51,6 +51,7 @@ public static function triggerEvent(Recipe $recipe, ?array &$context = NULL): vo
     $event = new RecipeAppliedEvent($recipe);
     \Drupal::service(EventDispatcherInterface::class)->dispatch($event);
     $context['message'] = t('Applied %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['recipe'][] = $recipe->name;
   }
 
   /**
@@ -248,6 +249,7 @@ public static function installModule(string $module, StorageInterface|Recipe $re
     \Drupal::service('module_installer')->install([$module]);
     \Drupal::service('config.installer')->setSyncing(FALSE);
     $context['message'] = t('Installed %module module.', ['%module' => \Drupal::service('extension.list.module')->getName($module)]);
+    $context['results']['module'][] = $module;
   }
 
   /**
@@ -277,6 +279,7 @@ public static function installTheme(string $theme, StorageInterface|Recipe $reci
     \Drupal::service('theme_installer')->install([$theme]);
     \Drupal::service('config.installer')->setSyncing(FALSE);
     $context['message'] = t('Installed %theme theme.', ['%theme' => \Drupal::service('extension.list.theme')->getName($theme)]);
+    $context['results']['theme'][] = $theme;
   }
 
   /**
@@ -290,6 +293,7 @@ public static function installTheme(string $theme, StorageInterface|Recipe $reci
   public static function installConfig(Recipe $recipe, ?array &$context = NULL): void {
     static::processConfiguration($recipe->config);
     $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['config'][] = $recipe->name;
   }
 
   /**
@@ -303,6 +307,7 @@ public static function installConfig(Recipe $recipe, ?array &$context = NULL): v
   public static function installContent(Recipe $recipe, ?array &$context = NULL): void {
     static::processContent($recipe->content);
     $context['message'] = t('Created content for %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['content'][] = $recipe->name;
   }
 
 }
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
index 75862c08b307..229d4acfdacf 100644
--- a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
@@ -37,7 +37,7 @@ public function testRecipeCommand(): void {
 
     $process = $this->applyRecipe('core/tests/fixtures/recipes/install_node_with_config');
     $this->assertSame(0, $process->getExitCode());
-    $this->assertSame('', $process->getErrorOutput());
+    $this->assertStringContainsString("6/6 [â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“]\nApplied Install node with config recipe.", $process->getErrorOutput());
     $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput());
     $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
     $this->assertCheckpointsExist(["Backup before the 'Install node with config' recipe."]);
@@ -45,7 +45,7 @@ public function testRecipeCommand(): void {
     // Ensure recipes can be applied without affecting pre-existing checkpoints.
     $process = $this->applyRecipe('core/tests/fixtures/recipes/install_two_modules');
     $this->assertSame(0, $process->getExitCode());
-    $this->assertSame('', $process->getErrorOutput());
+    $this->assertStringContainsString("1/1 [â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“]\nApplied Install two modules recipe.", $process->getErrorOutput());
     $this->assertStringContainsString('Install two modules applied successfully', $process->getOutput());
     $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
     $this->assertCheckpointsExist([
@@ -68,7 +68,7 @@ public function testRecipeCommand(): void {
     \Drupal::service('config.storage.checkpoint')->checkpoint('Test log message');
     $process = $this->applyRecipe('core/tests/fixtures/recipes/no_extensions');
     $this->assertSame(0, $process->getExitCode());
-    $this->assertSame('', $process->getErrorOutput());
+    $this->assertStringContainsString("1/1 [â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“]\nApplied No extensions recipe.", $process->getErrorOutput());
     $this->assertCheckpointsExist([
       "Backup before the 'Install node with config' recipe.",
       "Backup before the 'Install two modules' recipe.",
diff --git a/phpstan-level9-baseline.neon b/phpstan-level9-baseline.neon
index 407f70f8bdaa..82f59183f735 100644
--- a/phpstan-level9-baseline.neon
+++ b/phpstan-level9-baseline.neon
@@ -242,7 +242,7 @@ parameters:
 
 		-
 			message: "#^\\\\Drupal calls should be avoided in classes, use dependency injection instead$#"
-			count: 1
+			count: 3
 			path: core/lib/Drupal/Core/Recipe/RecipeCommand.php
 
 		-
-- 
GitLab