diff --git a/automatic_updates.module b/automatic_updates.module index 7e32da52076f4def9dd40dae21c9e1f4ed947e8a..37b95909b2919d7392f1ab12caebb560653074bd 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 74ee2dc8f5b381f156a539df66a5e3373ebf6eae..4d9acc7b5b93887644393b2165733cf518cd9a36 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 f82eebc3426b473230b08026f685273fd8137a1a..9f68cfc0615caa368ccbacbd4dc585b018cd0555 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 c95c7c68c17d4356eb90ad0c6856633b115b58a2..92158f26f04689d91d10f4043adb50f26db8fc6c 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 3d4aeb6a1d69fecafb6651de05dfb0135fa5937a..2d388b18b280c9ded2b223832b06482ebb861119 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 9eb1231436bdd4d17d805e11455423a21f78b743..0000000000000000000000000000000000000000 --- 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 28b8d7ee263c91051bb312b71ea82dd911bcbf5f..0000000000000000000000000000000000000000 --- 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 2e918ad2d751df11a1db5df22e3151f4f92f74cc..f45c969b4fd2f36bc8052caf66efdcd69001f9ff 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 6cfeae3f593dc3bf5949b4aa842aab0f057dc5a4..0000000000000000000000000000000000000000 --- 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 48728582ed2afafb75ebeacec3d6131b4ef22c57..e538a94fa67b948938ad187a6c1417ca583ac632 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. *