From 24ce450ad2d3f82e85fa83bf0a3b4b5a6bb432aa Mon Sep 17 00:00:00 2001
From: Ted Bowman <41201-tedbow@users.noreply.drupalcode.org>
Date: Sun, 26 Feb 2023 23:50:32 +0000
Subject: [PATCH] Issue #3344039 by phenaproxima, tedbow, Wim Leers: Add a
 validate() method to ComposerInspector to ensure that Composer is usable

---
 automatic_updates.module                      |   4 +-
 package_manager/package_manager.module        |   4 +-
 package_manager/package_manager.services.yml  |  11 +-
 package_manager/src/ComposerInspector.php     | 117 ++++++-
 .../Validator/ComposerExecutableValidator.php | 143 ++------
 .../Validator/ComposerJsonExistsValidator.php |  63 ----
 .../ComposerExecutableValidatorTest.php       | 319 ------------------
 .../src/Kernel/ComposerInspectorTest.php      | 163 ++++++++-
 .../ComposerJsonExistsValidatorTest.php       |  65 ----
 .../src/Kernel/LockFileValidatorTest.php      |  23 ++
 10 files changed, 325 insertions(+), 587 deletions(-)
 delete mode 100644 package_manager/src/Validator/ComposerJsonExistsValidator.php
 delete mode 100644 package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
 delete mode 100644 package_manager/tests/src/Kernel/ComposerJsonExistsValidatorTest.php

diff --git a/automatic_updates.module b/automatic_updates.module
index 7e32da5207..37b95909b2 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -12,8 +12,8 @@ use Drupal\automatic_updates\CronUpdater;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\automatic_updates\Validation\AdminStatusCheckMessages;
 use Drupal\Core\Url;
+use Drupal\package_manager\ComposerInspector;
 use Drupal\system\Controller\DbUpdateController;
-use Drupal\package_manager\Validator\ComposerExecutableValidator;
 
 /**
  * Implements hook_help().
@@ -31,7 +31,7 @@ function automatic_updates_help($route_name, RouteMatchInterface $route_match) {
       $output .= '</p>';
       $output .= '<p>' . t('Additionally, Automatic Updates periodically runs checks to ensure that updates can be installed, and will warn site administrators if problems are detected.') . '</p>';
       $output .= '<h3>' . t('Requirements') . '</h3>';
-      $output .= '<p>' . t('Automatic Updates requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION_CONSTRAINT]) . '</p>';
+      $output .= '<p>' . t('Automatic Updates requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</p>';
       $output .= '<p id="cron-alternate-port">' . t('If your site is running on the built-in PHP web server, unattended (i.e., cron) updates may not work without one of the following workarounds:') . '</p>';
       $output .= '<ul>';
       $output .= '<li>' . t('Use a multithreaded web server, such as Apache, NGINX, or on Windows, IIS.') . '</li>';
diff --git a/package_manager/package_manager.module b/package_manager/package_manager.module
index 74ee2dc8f5..4d9acc7b5b 100644
--- a/package_manager/package_manager.module
+++ b/package_manager/package_manager.module
@@ -8,7 +8,7 @@
 declare(strict_types = 1);
 
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\package_manager\Validator\ComposerExecutableValidator;
+use Drupal\package_manager\ComposerInspector;
 
 // cspell:ignore grasmash
 
@@ -25,7 +25,7 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) {
       $output .= '<h3 id="package-manager-requirements">' . t('Requirements') . '</h3>';
       $output .= '<ul>';
       $output .= '  <li>' . t("The Drupal application's codebase must be writable in order to use Automatic Updates. This includes Drupal core, modules, themes and the Composer dependencies in the <code>vendor</code> directory. This makes Automatic Updates incompatible with some hosting platforms.") . '</li>';
-      $output .= '  <li>' . t('Package Manager requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be stored in config, or it will be automatically detected. To set the path to Composer, you can add the following line to settings.php:', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION_CONSTRAINT]) . '</li>';
+      $output .= '  <li>' . t('Package Manager requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be stored in config, or it will be automatically detected. To set the path to Composer, you can add the following line to settings.php:', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</li>';
       $output .= '</ul>';
 
       $output .= '<h3 id="package-manager-limitations">' . t('Limitations') . '</h3>';
diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml
index f82eebc342..9f68cfc061 100644
--- a/package_manager/package_manager.services.yml
+++ b/package_manager/package_manager.services.yml
@@ -68,10 +68,9 @@ services:
   package_manager.validator.composer_executable:
     class: Drupal\package_manager\Validator\ComposerExecutableValidator
     arguments:
-      - '@PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface'
+      - '@package_manager.composer_inspector'
+      - '@package_manager.path_locator'
       - '@module_handler'
-      - '@PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface'
-      - '@PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface'
     tags:
       - { name: event_subscriber }
   package_manager.validator.disk_space:
@@ -149,12 +148,6 @@ services:
       - '@extension.list.theme'
     tags:
       - { name: event_subscriber }
-  package_manager.validator.composer_json_exists:
-    class: Drupal\package_manager\Validator\ComposerJsonExistsValidator
-    arguments:
-      - '@package_manager.path_locator'
-    tags:
-      - { name: event_subscriber }
   package_manager.test_site_excluder:
     class: Drupal\package_manager\PathExcluder\TestSiteExcluder
     arguments:
diff --git a/package_manager/src/ComposerInspector.php b/package_manager/src/ComposerInspector.php
index c95c7c68c1..92158f26f0 100644
--- a/package_manager/src/ComposerInspector.php
+++ b/package_manager/src/ComposerInspector.php
@@ -4,9 +4,14 @@ declare(strict_types = 1);
 
 namespace Drupal\package_manager;
 
+use Composer\Semver\Semver;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
 use PhpTuf\ComposerStager\Domain\Exception\RuntimeException;
+use PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface;
 use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
 use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
 
 /**
  * Defines a class to get information from Composer.
@@ -16,7 +21,9 @@ use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
  *   at any time without warning. External code should not interact with this
  *   class.
  */
-final class ComposerInspector {
+class ComposerInspector {
+
+  use StringTranslationTrait;
 
   /**
    * The JSON process output callback.
@@ -39,16 +46,116 @@ final class ComposerInspector {
    */
   private array $lockFileHashes = [];
 
+  /**
+   * A semantic version constraint for the supported version(s) of Composer.
+   *
+   * @var string
+   */
+  final public const SUPPORTED_VERSION = '~2.2.12 || ^2.3.5';
+
   /**
    * Constructs a ComposerInspector object.
    *
    * @param \PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface $runner
    *   The Composer runner service from Composer Stager.
+   * @param \PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface $composerIsAvailable
+   *   The Composer Stager precondition to ensure that Composer is available.
+   * @param \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface $pathFactory
+   *   The path factory service from Composer Stager.
    */
-  public function __construct(private ComposerRunnerInterface $runner) {
+  public function __construct(private ComposerRunnerInterface $runner, private ComposerIsAvailableInterface $composerIsAvailable, private PathFactoryInterface $pathFactory) {
     $this->jsonCallback = new JsonProcessOutputCallback();
   }
 
+  /**
+   * Checks that Composer commands can be run.
+   *
+   * @param string $working_dir
+   *   The directory in which Composer will be run.
+   *
+   * @throws \Exception
+   *   If any of the following are true:
+   *   - The Composer executable is not available.
+   *   - composer.json does not exist in the given directory.
+   *   - composer.lock does not exist in the given directory.
+   *   - The detected version of Composer is not supported.
+   */
+  public function validate(string $working_dir): void {
+    $messages = [];
+
+    // Ensure the Composer executable is available. For performance reasons,
+    // statically cache the result, since it's unlikely to change during the
+    // current request. If $unavailable_message is NULL, it means we haven't
+    // done this check yet. If it's FALSE, it means we did the check and there
+    // were no errors; and, if it's a string, it's the error message we received
+    // the last time we did this check.
+    static $unavailable_message;
+    if ($unavailable_message === NULL) {
+      try {
+        // The "Composer is available" precondition requires active and stage
+        // directories, but they don't actually matter to it, nor do path
+        // exclusions, so dummies can be passed for simplicity.
+        $active_dir = $this->pathFactory::create($working_dir);
+        $stage_dir = $active_dir;
+
+        $this->composerIsAvailable->assertIsFulfilled($active_dir, $stage_dir);
+        $unavailable_message = FALSE;
+      }
+      catch (PreconditionException $e) {
+        $unavailable_message = $e->getMessage();
+      }
+    }
+    if ($unavailable_message) {
+      $messages[] = $unavailable_message;
+    }
+
+    // The detected version of Composer is unlikely to change during the
+    // current request, so statically cache it. If $unsupported_message is NULL,
+    // it means we haven't done this check yet. If it's FALSE, it means we did
+    // the check and there were no errors; and, if it's a string, it's the error
+    // message we received the last time we did this check.
+    static $unsupported_message;
+    if ($unsupported_message === NULL) {
+      try {
+        $detected_version = $this->getVersion($working_dir);
+
+        if (Semver::satisfies($detected_version, static::SUPPORTED_VERSION)) {
+          // We did the version check, and it did not produce an error message.
+          $unsupported_message = FALSE;
+        }
+        else {
+          $unsupported_message = $this->t('The detected Composer version, @version, does not satisfy <code>@constraint</code>.', [
+            '@version' => $detected_version,
+            '@constraint' => static::SUPPORTED_VERSION,
+          ]);
+        }
+      }
+      catch (\UnexpectedValueException $e) {
+        $unsupported_message = $e->getMessage();
+      }
+    }
+    if ($unsupported_message) {
+      $messages[] = $unsupported_message;
+    }
+
+    // Check for the presence of composer.json and composer.lock.
+    foreach (['json', 'lock'] as $suffix) {
+      $filename = 'composer.' . $suffix;
+
+      if (file_exists($working_dir . DIRECTORY_SEPARATOR . $filename)) {
+        continue;
+      }
+      $messages[] = $this->t('@filename not found in @dir.', [
+        '@filename' => $filename,
+        '@dir' => $working_dir,
+      ]);
+    }
+
+    if ($messages) {
+      throw new \Exception(implode("\n", $messages));
+    }
+  }
+
   /**
    * Returns a config value from Composer.
    *
@@ -66,6 +173,8 @@ final class ComposerInspector {
    * @see \Composer\Command\ConfigCommand::execute()
    */
   public function getConfig(string $key, string $working_dir) : ?string {
+    $this->validate($working_dir);
+
     // For whatever reason, PHPCS thinks that $output is not used, even though
     // it very clearly *is*. So, shut PHPCS up for the duration of this method.
     // phpcs:disable DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable
@@ -120,6 +229,8 @@ final class ComposerInspector {
    *
    * @throws \UnexpectedValueException
    *   Thrown if the expect data format is not found.
+   *
+   * @todo Make this method private in https://drupal.org/i/3344556.
    */
   public function getVersion(string $working_dir): string {
     $this->runner->run(['--format=json', "--working-dir=$working_dir"], $this->jsonCallback);
@@ -144,6 +255,8 @@ final class ComposerInspector {
    *   The installed packages list for the directory.
    */
   public function getInstalledPackagesList(string $working_dir): InstalledPackagesList {
+    $this->validate($working_dir);
+
     $working_dir = realpath($working_dir);
     $lock_file_path = $working_dir . DIRECTORY_SEPARATOR . 'composer.lock';
 
diff --git a/package_manager/src/Validator/ComposerExecutableValidator.php b/package_manager/src/Validator/ComposerExecutableValidator.php
index 3d4aeb6a1d..2d388b18b2 100644
--- a/package_manager/src/Validator/ComposerExecutableValidator.php
+++ b/package_manager/src/Validator/ComposerExecutableValidator.php
@@ -4,20 +4,15 @@ declare(strict_types = 1);
 
 namespace Drupal\package_manager\Validator;
 
-use Composer\Semver\Semver;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\Url;
+use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\package_manager\Event\StatusCheckEvent;
-use PhpTuf\ComposerStager\Domain\Exception\ExceptionInterface;
-use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
-use PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface;
-use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
-use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
-use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
+use Drupal\package_manager\PathLocator;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
@@ -32,110 +27,46 @@ class ComposerExecutableValidator implements EventSubscriberInterface {
 
   use StringTranslationTrait;
 
-  /**
-   * The minimum required version of Composer.
-   *
-   * @var string
-   */
-  public const MINIMUM_COMPOSER_VERSION_CONSTRAINT = '~2.2.12 || ^2.3.5';
-
   /**
    * Constructs a ComposerExecutableValidator object.
    *
-   * @param \PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface $composer
-   *   The Composer runner.
+   * @param \Drupal\package_manager\ComposerInspector $composerInspector
+   *   The Composer inspector service.
+   * @param \Drupal\package_manager\PathLocator $pathLocator
+   *   The path locator service.
    * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
    *   The module handler service.
-   * @param \PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface $composerIsAvailable
-   *   The "Composer is available" precondition service.
-   * @param \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface $pathFactory
-   *   The path factory service.
    */
   public function __construct(
-    protected ComposerRunnerInterface $composer,
+    protected ComposerInspector $composerInspector,
+    protected PathLocator $pathLocator,
     protected ModuleHandlerInterface $moduleHandler,
-    protected ComposerIsAvailableInterface $composerIsAvailable,
-    protected PathFactoryInterface $pathFactory,
   ) {}
 
   /**
    * Validates that the Composer executable is the correct version.
    */
   public function validate(PreOperationStageEvent $event): void {
-    // Return early if Composer is not available.
     try {
-      // The "Composer is available" precondition requires active and stage
-      // directories, but they don't actually matter to it, nor do path
-      // exclusions, so dummies can be passed for simplicity.
-      $active_dir = $this->pathFactory::create(__DIR__);
-      $stage_dir = $active_dir;
-
-      $this->composerIsAvailable->assertIsFulfilled($active_dir, $stage_dir);
+      $this->composerInspector->validate($this->pathLocator->getProjectRoot());
     }
-    catch (PreconditionException $e) {
-      if (!$this->moduleHandler->moduleExists('help')) {
-        $event->addErrorFromThrowable($e);
-        return;
+    catch (\Throwable $e) {
+      if ($this->moduleHandler->moduleExists('help')) {
+        $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+          ->setOption('fragment', 'package-manager-faq-composer-not-found')
+          ->toString();
+
+        $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to configure the path to Composer.', [
+          '@message' => $e->getMessage(),
+          ':package-manager-help' => $url,
+        ]);
+        $event->addError([$message]);
       }
-      $this->setError($e->getMessage(), $event);
-      return;
-    }
-
-    try {
-      $output = $this->runCommand();
-    }
-    catch (ExceptionInterface $e) {
-      if (!$this->moduleHandler->moduleExists('help')) {
+      else {
         $event->addErrorFromThrowable($e);
-        return;
-      }
-      $this->setError($e->getMessage(), $event);
-      return;
-    }
-
-    $matched = [];
-    // Search for a semantic version number and optional stability flag.
-    if (preg_match('/([0-9]+\.?){3}-?((alpha|beta|rc)[0-9]*)?/i', $output, $matched)) {
-      $version = $matched[0];
-    }
-
-    if (isset($version)) {
-      if (!Semver::satisfies($version, static::MINIMUM_COMPOSER_VERSION_CONSTRAINT)) {
-        $message = $this->t('A Composer version which satisfies <code>@minimum_version</code> is required, but version @detected_version was detected.', [
-          '@minimum_version' => static::MINIMUM_COMPOSER_VERSION_CONSTRAINT,
-          '@detected_version' => $version,
-        ]);
-        $this->setError($message, $event);
       }
-    }
-    else {
-      $this->setError($this->t('The Composer version could not be detected.'), $event);
-    }
-  }
 
-  /**
-   * Flags a validation error.
-   *
-   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $message
-   *   The error message. If the Help module is enabled, a link to Package
-   *   Manager's online documentation will be appended.
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
-   *
-   * @see package_manager_help()
-   */
-  protected function setError($message, PreOperationStageEvent $event): void {
-    if ($this->moduleHandler->moduleExists('help')) {
-      $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
-        ->setOption('fragment', 'package-manager-faq-composer-not-found')
-        ->toString();
-
-      $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to configure the path to Composer.', [
-        '@message' => $message,
-        ':package-manager-help' => $url,
-      ]);
     }
-    $event->addError([$message]);
   }
 
   /**
@@ -149,36 +80,4 @@ class ComposerExecutableValidator implements EventSubscriberInterface {
     ];
   }
 
-  /**
-   * Runs `composer --version` and returns its output.
-   *
-   * @return string
-   *   The output of `composer --version`.
-   */
-  protected function runCommand(): string {
-    // For whatever reason, PHPCS thinks that $output is not used, even though
-    // it very clearly *is*. So, shut PHPCS up for the duration of this method.
-    // phpcs:disable
-    $callback = new class () implements ProcessOutputCallbackInterface {
-
-      /**
-       * The command output.
-       *
-       * @var string
-       */
-      public string $output = '';
-
-      /**
-       * {@inheritdoc}
-       */
-      public function __invoke(string $type, string $buffer): void {
-        $this->output .= $buffer;
-      }
-
-    };
-    $this->composer->run(['--version'], $callback);
-    return $callback->output;
-    // phpcs:enable
-  }
-
 }
diff --git a/package_manager/src/Validator/ComposerJsonExistsValidator.php b/package_manager/src/Validator/ComposerJsonExistsValidator.php
deleted file mode 100644
index 9eb1231436..0000000000
--- a/package_manager/src/Validator/ComposerJsonExistsValidator.php
+++ /dev/null
@@ -1,63 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\package_manager\Validator;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
-use Drupal\package_manager\Event\StatusCheckEvent;
-use Drupal\package_manager\PathLocator;
-
-/**
- * Validates that the active composer.json file exists.
- *
- * @internal
- *   This is an internal part of Package Manager and may be changed or removed
- *   at any time without warning. External code should not interact with this
- *   class.
- */
-final class ComposerJsonExistsValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * Constructs a ComposerJsonExistsValidator object.
-   *
-   * @param \Drupal\package_manager\PathLocator $pathLocator
-   *   The path locator service.
-   */
-  public function __construct(protected PathLocator $pathLocator) {
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents(): array {
-    // Set priority to 190 which puts it just after EnvironmentSupportValidator.
-    // @see \Drupal\package_manager\Validator\EnvironmentSupportValidator
-    return [
-      PreCreateEvent::class => ['validate', 190],
-      PreApplyEvent::class => ['validate', 190],
-      StatusCheckEvent::class => ['validate', 190],
-    ];
-  }
-
-  /**
-   * Validates that the active composer.json file exists.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event.
-   */
-  public function validate(PreOperationStageEvent $event): void {
-    $project_root = $this->pathLocator->getProjectRoot();
-    if (!file_exists($project_root . '/composer.json')) {
-      $event->addError([$this->t('No composer.json file can be found at @project_root', ['@project_root' => $project_root])]);
-      $event->stopPropagation();
-    }
-  }
-
-}
diff --git a/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
deleted file mode 100644
index 28b8d7ee26..0000000000
--- a/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
+++ /dev/null
@@ -1,319 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\Tests\package_manager\Kernel;
-
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\Core\Url;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Validator\ComposerExecutableValidator;
-use Drupal\package_manager\ValidationResult;
-use PhpTuf\ComposerStager\Domain\Exception\IOException;
-use PhpTuf\ComposerStager\Domain\Exception\LogicException;
-use PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinderInterface;
-use PHPUnit\Framework\Assert;
-use Symfony\Component\DependencyInjection\Reference;
-
-/**
- * @covers \Drupal\package_manager\Validator\ComposerExecutableValidator
- * @group package_manager
- * @internal
- */
-class ComposerExecutableValidatorTest extends PackageManagerKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    $container->getDefinition('package_manager.validator.composer_executable')
-      ->setClass(TestComposerExecutableValidator::class);
-    $container
-      ->register('test.terrible_composer_finder', TestFailingComposerFinder::class);
-  }
-
-  /**
-   * Tests that an error is raised if the Composer executable isn't found.
-   */
-  public function testErrorIfComposerNotFound(): void {
-    $exception = new IOException("This is your regularly scheduled error.");
-    TestComposerExecutableValidator::setCommandOutput($exception);
-
-    // The validator should translate that exception into an error.
-    $error = ValidationResult::createError([
-      $exception->getMessage(),
-    ]);
-    $this->assertStatusCheckResults([$error]);
-    $this->assertResults([$error], PreCreateEvent::class);
-
-    $this->enableModules(['help']);
-    $this->assertResultsWithHelp([$error], PreCreateEvent::class);
-  }
-
-  /**
-   * Test RuntimeError is handled correctly.
-   */
-  public function testComposerNotFound(): void {
-    // @see \PhpTuf\ComposerStager\Infrastructure\Service\Precondition\ComposerIsAvailable::getUnfulfilledStatusMessage()
-    $exception = new \Exception('Composer cannot be found.');
-    TestComposerExecutableValidator::setCommandOutput($exception);
-
-    // Change ComposerRunnerInterface path to throw a LogicException.
-    $definition = $this->container->getDefinition('PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface');
-    $definition->setArgument(0, new Reference('test.terrible_composer_finder'));
-
-    // The validator should translate that exception into an error.
-    $error = ValidationResult::createError([
-      $exception->getMessage(),
-    ]);
-    $this->assertStatusCheckResults([$error]);
-    $this->assertResults([$error], PreCreateEvent::class);
-  }
-
-  /**
-   * Tests error on pre-apply if the Composer executable isn't found.
-   */
-  public function testErrorIfComposerNotFoundDuringPreApply(): void {
-    // Setting command output which doesn't raise error for pre-create event.
-    TestComposerExecutableValidator::setCommandOutput("Composer version 2.2.12");
-    $exception = new IOException("This is your regularly scheduled error.");
-
-    $listener = function () use ($exception): void {
-      TestComposerExecutableValidator::setCommandOutput($exception);
-    };
-    $this->addEventTestListener($listener);
-
-    // The validator should translate that exception into an error.
-    $error = ValidationResult::createError([
-      $exception->getMessage(),
-    ]);
-    $stage = $this->assertResults([$error], PreApplyEvent::class);
-    $stage->destroy(TRUE);
-
-    // Setting command output which doesn't raise error for pre-create event.
-    TestComposerExecutableValidator::setCommandOutput("Composer version 2.2.12");
-    $this->enableModules(['help']);
-    $this->addEventTestListener($listener);
-    $this->assertResultsWithHelp([$error], PreApplyEvent::class, FALSE);
-  }
-
-  /**
-   * Data provider for testComposerVersionValidation().
-   *
-   * @return mixed[][]
-   *   The test cases.
-   */
-  public function providerComposerVersionValidation(): array {
-    // Invalid or undetectable Composer versions will always produce the same
-    // error.
-    $invalid_version = ValidationResult::createError([t('The Composer version could not be detected.')]);
-
-    // Unsupported Composer versions will report the detected version number
-    // in the validation result, so we need a function to churn out those fake
-    // results for the test method.
-    $unsupported_version = function (string $version): ValidationResult {
-      $minimum_version = ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION_CONSTRAINT;
-
-      return ValidationResult::createError([
-        t('A Composer version which satisfies <code>@minimum_version</code> is required, but version @version was detected.', [
-          '@minimum_version' => $minimum_version,
-          '@version' => $version,
-        ]),
-      ]);
-    };
-
-    return [
-      'Minimum version' => [
-        '2.2.12',
-        [],
-      ],
-      '2.2.13' => [
-        '2.2.13',
-        [],
-      ],
-      '2.3.6' => [
-        '2.3.6',
-        [],
-      ],
-      '2.4.1' => [
-        '2.4.1',
-        [],
-      ],
-      '2.2.11' => [
-        '2.2.11',
-        [$unsupported_version('2.2.11')],
-      ],
-      '2.3.4' => [
-        '2.3.4',
-        [$unsupported_version('2.3.4')],
-      ],
-      '2.1.6' => [
-        '2.1.6',
-        [$unsupported_version('2.1.6')],
-      ],
-      '1.10.22' => [
-        '1.10.22',
-        [$unsupported_version('1.10.22')],
-      ],
-      '1.7.3' => [
-        '1.7.3',
-        [$unsupported_version('1.7.3')],
-      ],
-      '2.0.0-alpha3' => [
-        '2.0.0-alpha3',
-        [$unsupported_version('2.0.0-alpha3')],
-      ],
-      '2.1.0-RC1' => [
-        '2.1.0-RC1',
-        [$unsupported_version('2.1.0-RC1')],
-      ],
-      '1.0.0-RC' => [
-        '1.0.0-RC',
-        [$unsupported_version('1.0.0-RC')],
-      ],
-      '1.0.0-beta1' => [
-        '1.0.0-beta1',
-        [$unsupported_version('1.0.0-beta1')],
-      ],
-      '1.9-dev' => [
-        '1.9-dev',
-        [$invalid_version],
-      ],
-      'Invalid version' => [
-        '@package_version@',
-        [$invalid_version],
-      ],
-    ];
-  }
-
-  /**
-   * Tests validation of various Composer versions.
-   *
-   * @param string $reported_version
-   *   The version of Composer that `composer --version` should report.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerComposerVersionValidation
-   */
-  public function testComposerVersionValidation(string $reported_version, array $expected_results): void {
-    TestComposerExecutableValidator::setCommandOutput("Composer version $reported_version");
-
-    // If the validator can't find a recognized, supported version of Composer,
-    // it should produce errors.
-    $this->assertStatusCheckResults($expected_results);
-    $this->assertResults($expected_results, PreCreateEvent::class);
-
-    $this->enableModules(['help']);
-    $this->assertResultsWithHelp($expected_results, PreCreateEvent::class);
-  }
-
-  /**
-   * Tests validation of various Composer versions on pre-apply.
-   *
-   * @param string $reported_version
-   *   The version of Composer that `composer --version` should report.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerComposerVersionValidation
-   */
-  public function testComposerVersionValidationDuringPreApply(string $reported_version, array $expected_results): void {
-    // Setting command output which doesn't raise error for pre-create event.
-    TestComposerExecutableValidator::setCommandOutput("Composer version 2.2.12");
-    $listener = function () use ($reported_version): void {
-      TestComposerExecutableValidator::setCommandOutput("Composer version $reported_version");
-    };
-    $this->addEventTestListener($listener);
-
-    // If the validator can't find a recognized, supported version of Composer,
-    // it should produce errors.
-    $stage = $this->assertResults($expected_results, PreApplyEvent::class);
-    $stage->destroy(TRUE);
-
-    // Setting command output which doesn't raise error for pre-create event.
-    TestComposerExecutableValidator::setCommandOutput("Composer version 2.2.12");
-    $this->enableModules(['help']);
-    $this->addEventTestListener($listener);
-    $this->assertResultsWithHelp($expected_results, PreApplyEvent::class, FALSE);
-  }
-
-  /**
-   * Asserts that a set of validation results link to the Package Manager help.
-   *
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   * @param string|null $event_class
-   *   (optional) The class of the event which should return the results. Must
-   *   be passed if $expected_results is not empty.
-   * @param bool $assert_status_check
-   *   (optional) Whether the status checks should be asserted. Defaults to
-   *   TRUE.
-   */
-  private function assertResultsWithHelp(array $expected_results, string $event_class = NULL, bool $assert_status_check = TRUE): void {
-    $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
-      ->setOption('fragment', 'package-manager-faq-composer-not-found')
-      ->toString();
-
-    // Reformat the provided results so that they all have the link to the
-    // online documentation appended to them.
-    $map = function (string $message) use ($url): string {
-      return $message . ' See <a href="' . $url . '">the help page</a> for information on how to configure the path to Composer.';
-    };
-    foreach ($expected_results as $index => $result) {
-      $messages = array_map($map, $result->getMessages());
-      $expected_results[$index] = ValidationResult::createError($messages);
-    }
-    if ($assert_status_check) {
-      $this->assertStatusCheckResults($expected_results);
-    }
-    $this->assertResults($expected_results, $event_class);
-  }
-
-}
-
-/**
- * A test-only version of ComposerExecutableValidator that returns set output.
- */
-class TestComposerExecutableValidator extends ComposerExecutableValidator {
-
-  /**
-   * Sets the output of `composer --version`.
-   *
-   * @param string|\Throwable $output
-   *   The output of the command, or an exception to throw.
-   */
-  public static function setCommandOutput($output): void {
-    \Drupal::state()->set(static::class, $output);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function runCommand(): string {
-    $output = \Drupal::state()->get(static::class);
-    Assert::assertNotNull($output, __CLASS__ . '::setCommandOutput() should have been called first 💩');
-    if ($output instanceof \Throwable) {
-      throw $output;
-    }
-    return $output;
-  }
-
-}
-
-/**
- * A test-only version of ExecutableFinderInterface that throws LogicException.
- */
-class TestFailingComposerFinder implements ExecutableFinderInterface {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function find(string $name): string {
-    throw new LogicException();
-  }
-
-}
diff --git a/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/package_manager/tests/src/Kernel/ComposerInspectorTest.php
index 2e918ad2d7..f45c969b4f 100644
--- a/package_manager/tests/src/Kernel/ComposerInspectorTest.php
+++ b/package_manager/tests/src/Kernel/ComposerInspectorTest.php
@@ -7,8 +7,14 @@ namespace Drupal\Tests\package_manager\Kernel;
 use Composer\Json\JsonFile;
 use Drupal\Component\Serialization\Json;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\InstalledPackage;
+use Drupal\package_manager\JsonProcessOutputCallback;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
 use PhpTuf\ComposerStager\Domain\Exception\RuntimeException;
+use PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
+use Prophecy\Argument;
 
 /**
  * @coversDefaultClass \Drupal\package_manager\ComposerInspector
@@ -21,7 +27,8 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
    * @covers ::getConfig
    */
   public function testConfig(): void {
-    $dir = __DIR__ . '/../../fixtures/fake_site';
+    $dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
     $inspector = $this->container->get('package_manager.composer_inspector');
     $this->assertSame(1, Json::decode($inspector->getConfig('secure-http', $dir)));
 
@@ -37,8 +44,19 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
       'baz' => NULL,
     ], Json::decode($inspector->getConfig('extra', $dir)));
 
-    $this->expectException(RuntimeException::class);
-    $inspector->getConfig('non-existent-config', $dir);
+    try {
+      $inspector->getConfig('non-existent-config', $dir);
+      $this->fail('Expected an exception when trying to get a non-existent config key, but none was thrown.');
+    }
+    catch (RuntimeException) {
+      // We don't need to do anything here.
+    }
+
+    // If composer.json is removed, we should get an exception because
+    // getConfig() should validate that $dir is Composer-ready.
+    unlink($dir . '/composer.json');
+    $this->expectExceptionMessage("composer.json not found in $dir.");
+    $inspector->getConfig('extra', $dir);
   }
 
   /**
@@ -103,6 +121,145 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
     unset($lock_data['_readme']);
     $lock_file->write($lock_data);
     $this->assertNotSame($list, $inspector->getInstalledPackagesList($project_root));
+
+    // If composer.lock is removed, we should get an exception because
+    // getInstalledPackagesList() should validate that $project_root is
+    // Composer-ready.
+    unlink($lock_file->getPath());
+    $this->expectExceptionMessage("composer.lock not found in $project_root.");
+    $inspector->getInstalledPackagesList($project_root);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testComposerUnavailable(): void {
+    $precondition = $this->prophesize(ComposerIsAvailableInterface::class);
+    $mocked_precondition = $precondition->reveal();
+    $this->container->set(ComposerIsAvailableInterface::class, $mocked_precondition);
+
+    $precondition->assertIsFulfilled(Argument::cetera())
+      ->willThrow(new PreconditionException($mocked_precondition, "Well, that didn't work."))
+      // The result of the precondition is statically cached, so it should only
+      // be called once even though we call validate() twice.
+      ->shouldBeCalledOnce();
+
+    $project_root = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    /** @var \Drupal\package_manager\ComposerInspector $inspector */
+    $inspector = $this->container->get('package_manager.composer_inspector');
+    try {
+      $inspector->validate($project_root);
+      $this->fail('Expected an exception to be thrown, but it was not.');
+    }
+    catch (\Throwable $e) {
+      $this->assertSame("Well, that didn't work.", $e->getMessage());
+    }
+
+    // Call validate() again to ensure the precondition is called once.
+    $this->expectExceptionMessage("Well, that didn't work.");
+    $inspector->validate($project_root);
+  }
+
+  /**
+   * Tests what happens when composer.json or composer.lock are missing.
+   *
+   * @param string $filename
+   *   The filename to delete, which should cause validate() to raise an
+   *   error.
+   *
+   * @covers ::validate
+   *
+   * @testWith ["composer.json"]
+   *   ["composer.lock"]
+   */
+  public function testComposerFilesDoNotExist(string $filename): void {
+    $project_root = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $file_path = $project_root . '/' . $filename;
+    unlink($file_path);
+
+    $this->expectExceptionMessage("$filename not found in $project_root.");
+    $this->container->get('package_manager.composer_inspector')
+      ->validate($project_root);
+  }
+
+  /**
+   * @param string|null $reported_version
+   *   The version of Composer that will be returned by ::getVersion().
+   * @param string|null $expected_message
+   *   The error message that should be generated for the reported version of
+   *   Composer. If not passed, will default to the message format defined in
+   *   ::validate().
+   *
+   * @covers ::validate
+   *
+   * @testWith ["2.2.12", null]
+   *   ["2.2.13", null]
+   *   ["2.3.6", null]
+   *   ["2.4.1", null]
+   *   ["2.2.11", "<default>"]
+   *   ["2.3.4", "<default>"]
+   *   ["2.1.6", "<default>"]
+   *   ["1.10.22", "<default>"]
+   *   ["1.7.3", "<default>"]
+   *   ["2.0.0-alpha3", "<default>"]
+   *   ["2.1.0-RC1", "<default>"]
+   *   ["1.0.0-RC", "<default>"]
+   *   ["1.0.0-beta1", "<default>"]
+   *   ["1.9-dev", "<default>"]
+   *   ["@package_version@", "Invalid version string \"@package_version@\""]
+   *   [null, "Unable to determine Composer version"]
+   */
+  public function testVersionCheck(?string $reported_version, ?string $expected_message): void {
+    $runner = $this->prophesize(ComposerRunnerInterface::class);
+
+    $pass_version_to_output_callback = function (array $arguments_passed_to_runner) use ($reported_version): void {
+      $command_output = Json::encode([
+        'application' => [
+          'name' => 'Composer',
+          'version' => $reported_version,
+        ],
+      ]);
+
+      /** @var \Drupal\package_manager\JsonProcessOutputCallback $callback */
+      [, $callback] = $arguments_passed_to_runner;
+      $callback(JsonProcessOutputCallback::OUT, $command_output);
+    };
+
+    // We expect the runner to be called with two arguments: an array whose
+    // first item is `--format=json`, and an output callback. The result of the
+    // version check is statically cached, so the runner should only be called
+    // once, even though we call validate() twice in this test.
+    $runner->run(
+      Argument::withEntry(0, '--format=json'),
+      Argument::type(JsonProcessOutputCallback::class)
+    )->will($pass_version_to_output_callback)->shouldBeCalledOnce();
+    $this->container->set(ComposerRunnerInterface::class, $runner->reveal());
+
+    if ($expected_message === '<default>') {
+      $expected_message = "The detected Composer version, $reported_version, does not satisfy <code>" . ComposerInspector::SUPPORTED_VERSION . '</code>.';
+    }
+
+    $project_root = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    /** @var \Drupal\package_manager\ComposerInspector $inspector */
+    $inspector = $this->container->get('package_manager.composer_inspector');
+    try {
+      $inspector->validate($project_root);
+      // If we expected the version check to succeed, ensure we did not expect
+      // an exception message.
+      $this->assertNull($expected_message, 'Expected an exception, but none was thrown.');
+    }
+    catch (\Throwable $e) {
+      $this->assertSame($expected_message, $e->getMessage());
+    }
+
+    if (isset($expected_message)) {
+      $this->expectExceptionMessage($expected_message);
+    }
+    $inspector->validate($project_root);
   }
 
 }
diff --git a/package_manager/tests/src/Kernel/ComposerJsonExistsValidatorTest.php b/package_manager/tests/src/Kernel/ComposerJsonExistsValidatorTest.php
deleted file mode 100644
index 6cfeae3f59..0000000000
--- a/package_manager/tests/src/Kernel/ComposerJsonExistsValidatorTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\Tests\package_manager\Kernel;
-
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\Event\StatusCheckEvent;
-use Drupal\package_manager\ValidationResult;
-
-/**
- * @covers \Drupal\package_manager\Validator\ComposerJsonExistsValidator
- * @group package_manager
- */
-class ComposerJsonExistsValidatorTest extends PackageManagerKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['package_manager_test_validation'];
-
-  /**
-   * Tests validation when the active composer.json is not present.
-   */
-  public function testComposerRequirement(): void {
-    $listener = function (StageEvent $event): void {
-      unlink($this->container->get('package_manager.path_locator')
-        ->getProjectRoot() . '/composer.json');
-    };
-    $this->addEventTestListener($listener, PreCreateEvent::class, 1000);
-    $result = ValidationResult::createError([t(
-      'No composer.json file can be found at <PROJECT_ROOT>'),
-    ]);
-    foreach ([PreCreateEvent::class, StatusCheckEvent::class] as $event_class) {
-      $this->assertEventPropagationStopped($event_class, [$this->container->get('package_manager.validator.composer_json_exists'), 'validate']);
-    }
-    $this->assertResults([$result], PreCreateEvent::class);
-    $result = ValidationResult::createError(
-      [
-        t("Composer could not find the config file: <PROJECT_ROOT>/composer.json\n"),
-      ],
-      t("Unable to collect ignored paths, therefore can't perform status checks.")
-    );
-
-    $this->assertStatusCheckResults([$result]);
-  }
-
-  /**
-   * Tests that active composer.json is not present during pre-apply.
-   */
-  public function testComposerRequirementDuringPreApply(): void {
-    $result = ValidationResult::createError([t(
-      'No composer.json file can be found at <PROJECT_ROOT>'),
-    ]);
-    $this->addEventTestListener(function (): void {
-      unlink($this->container->get('package_manager.path_locator')
-        ->getProjectRoot() . '/composer.json');
-    });
-    $this->assertEventPropagationStopped(PreApplyEvent::class, [$this->container->get('package_manager.validator.composer_json_exists'), 'validate']);
-    $this->assertResults([$result], PreApplyEvent::class);
-  }
-
-}
diff --git a/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/package_manager/tests/src/Kernel/LockFileValidatorTest.php
index 48728582ed..e538a94fa6 100644
--- a/package_manager/tests/src/Kernel/LockFileValidatorTest.php
+++ b/package_manager/tests/src/Kernel/LockFileValidatorTest.php
@@ -4,12 +4,15 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Validator\LockFileValidator;
 use Drupal\package_manager\ValidationResult;
 use Drupal\package_manager_bypass\NoOpStager;
+use Prophecy\Argument;
 
 /**
  * @coversDefaultClass \Drupal\package_manager\Validator\LockFileValidator
@@ -34,6 +37,26 @@ class LockFileValidatorTest extends PackageManagerKernelTestBase {
       ->getProjectRoot();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $service_id = 'package_manager.composer_inspector';
+    $container->getDefinition($service_id)->setPublic(TRUE);
+
+    // Temporarily mock the Composer inspector to prevent it from complaining
+    // over the lack of a lock file if it's invoked by other validators.
+    $inspector = $this->prophesize(ComposerInspector::class);
+    $arguments = Argument::cetera();
+    $inspector->getConfig('allow-plugins', $arguments)->willReturn('[]');
+    $inspector->getConfig('secure-http', $arguments)->willReturn('1');
+    $inspector->getConfig('minimum-stability', $arguments)->willReturn('stable');
+    $inspector->validate($arguments);
+    $container->set($service_id, $inspector->reveal());
+  }
+
   /**
    * Tests that if no active lock file exists, a stage cannot be created.
    *
-- 
GitLab