diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php index 720c5387283c4bd5caf323b6d2fdd5876fda1eea..896e5c6774a23a57c5e74ec4c6775bd0c587181b 100644 --- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php +++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php @@ -198,7 +198,7 @@ class RandomDataPlugin extends ProjectBrowserSourceBase { author: [ 'name' => $this->randomGenerator->word(10), ], - composerNamespace: 'random/' . $machine_name, + packageName: 'random/' . $machine_name, categories: [$categories[array_rand($categories)]], images: $project_images, ); diff --git a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php index 6375836f8c81cb917f6770ad94fee48e7c3bfe24..5d150425e5ee3d2fb19009650b7634a369fefb2f 100644 --- a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php +++ b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php @@ -154,7 +154,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { changed: $project_from_source['updated_at'], created: $project_from_source['created_at'], author: $author, - composerNamespace: $project_from_source['composer_namespace'], + packageName: $project_from_source['composer_namespace'], categories: $categories, // Images: Array of images using the same structure as $logo, above. images: [], @@ -182,7 +182,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { changed: $project_from_source['updated_at'], created: $project_from_source['created_at'], author: $author, - composerNamespace: $project_from_source['composer_namespace'], + packageName: $project_from_source['composer_namespace'], categories: $categories, // Images: Array of images using the same structure as $logo, above. images: [], diff --git a/project_browser.services.yml b/project_browser.services.yml index 0d657f75361fbb2488077ced97b3fb056221071c..00cb8599d7d49935297c376724f2d546aaab1d01 100644 --- a/project_browser.services.yml +++ b/project_browser.services.yml @@ -27,3 +27,12 @@ services: arguments: ['@config.factory'] tags: - { name: event_subscriber } + Drupal\project_browser\Activator: + tags: + - { name: service_collector, tag: project_browser.activator, call: addActivator } + Drupal\project_browser\ActivatorInterface: '@Drupal\project_browser\Activator' + Drupal\project_browser\ModuleActivator: + autowire: true + public: false + tags: + - { name: project_browser.activator } diff --git a/src/Activator.php b/src/Activator.php new file mode 100644 index 0000000000000000000000000000000000000000..9e38e32c35ab8b976621de9026f92e14515ea346 --- /dev/null +++ b/src/Activator.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\HttpFoundation\Response; + +/** + * A generalized activator that can handle any type of project. + * + * This is a service collector that tries to delegate to the first registered + * activator that says it supports a given project. + */ +final class Activator implements ActivatorInterface { + + /** + * The registered activators. + * + * @var \Drupal\project_browser\ActivatorInterface[] + */ + private array $activators = []; + + /** + * Registers an activator. + * + * @param \Drupal\project_browser\ActivatorInterface $activator + * The activator to register. + */ + public function addActivator(ActivatorInterface $activator): void { + if (in_array($activator, $this->activators, TRUE)) { + return; + } + $this->activators[] = $activator; + } + + /** + * Returns the registered activator to handle a given project. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project object. + * + * @return \Drupal\project_browser\ActivatorInterface + * The activator which can handle the given project. + * + * @throws \InvalidArgumentException + * Thrown if none of the registered activators can handle the given project. + */ + private function getActivatorForProject(Project $project): ActivatorInterface { + foreach ($this->activators as $activator) { + if ($activator->supports($project)) { + return $activator; + } + } + throw new \InvalidArgumentException("The project '$project->machineName' is not supported by any registered activators."); + } + + /** + * {@inheritdoc} + */ + public function isActive(Project $project): bool { + return $this->getActivatorForProject($project)->isActive($project); + } + + /** + * {@inheritdoc} + */ + public function supports(Project $project): bool { + try { + return $this->getActivatorForProject($project) instanceof ActivatorInterface; + } + catch (\InvalidArgumentException) { + return FALSE; + } + } + + /** + * {@inheritdoc} + */ + public function activate(Project $project): ?Response { + return $this->getActivatorForProject($project)->activate($project); + } + +} diff --git a/src/ActivatorInterface.php b/src/ActivatorInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..103707273cb85d704b125abe338e6c6e6eaea60f --- /dev/null +++ b/src/ActivatorInterface.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\HttpFoundation\Response; + +/** + * Defines an interface for services which can activate projects. + * + * An activator is the "source of truth" about the state of a particular project + * in the current site -- for example, an activator that handles modules knows + * if the module is already installed. + */ +interface ActivatorInterface { + + /** + * Determines if a particular project is activated on the current site. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project to check. + * + * @return bool + * TRUE if the project is activated on the current site, FALSE otherwise. + */ + public function isActive(Project $project): bool; + + /** + * Determines if this activator can handle a particular project. + * + * For example, an activator that handles themes might return TRUE from this + * method if the project's Composer package type is `drupal-theme`. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project to check. + * + * @return bool + * TRUE if this activator is responsible for the given project, FALSE + * otherwise. + */ + public function supports(Project $project): bool; + + /** + * Activates a project on the current site. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project to activate. + * + * @return \Symfony\Component\HttpFoundation\Response|null + * Optionally, a response that should be presented to the user in Project + * Browser. This could be a set of additional instructions to display in a + * modal, for example, or a redirect to a configuration form. + */ + public function activate(Project $project): ?Response; + +} diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 126245d5b7c896d03f82590efba186c1a25a7a38..87c973c0c2927201c14e41a620038a3654af3cce 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -5,11 +5,11 @@ namespace Drupal\project_browser\Controller; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Controller\ControllerBase; -use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\TempStore\SharedTempStore; use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\Core\Url; use Drupal\package_manager\Exception\StageException; +use Drupal\project_browser\ActivatorInterface; use Drupal\project_browser\ComposerInstaller\Installer; use Drupal\project_browser\EnabledSourceHandler; use Psr\Log\LoggerInterface; @@ -64,22 +64,22 @@ class InstallerController extends ControllerBase { * The installer service. * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_temp_store_factory * The temporary storage factory. - * @param \Drupal\Core\Extension\ModuleInstallerInterface $moduleInstaller - * The the module installer. * @param \Drupal\project_browser\EnabledSourceHandler $enabledSourceHandler * The enabled project browser source. * @param \Drupal\Component\Datetime\TimeInterface $time * The system time. * @param \Psr\Log\LoggerInterface $logger * The logger instance. + * @param \Drupal\project_browser\ActivatorInterface $activator + * The project activator service. */ public function __construct( private readonly Installer $installer, SharedTempStoreFactory $shared_temp_store_factory, - private readonly ModuleInstallerInterface $moduleInstaller, private readonly EnabledSourceHandler $enabledSourceHandler, private readonly TimeInterface $time, private readonly LoggerInterface $logger, + private readonly ActivatorInterface $activator, ) { $this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser'); } @@ -91,10 +91,10 @@ class InstallerController extends ControllerBase { return new static( $container->get('project_browser.installer'), $container->get('tempstore.shared'), - $container->get('module_installer'), $container->get('project_browser.enabled_source'), $container->get('datetime.time'), $container->get('logger.channel.project_browser'), + $container->get(ActivatorInterface::class), ); } @@ -140,8 +140,10 @@ class InstallerController extends ControllerBase { /** * Returns the status of the project in the temp store. * + * @param string $source + * The source plugin ID. * @param string $project_id - * The project machine name. + * The ID of the project, as known to the source plugin. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Information about the project's require/install status. @@ -153,11 +155,14 @@ class InstallerController extends ControllerBase { * regularly so it can monitor the progress of the process and report which * stage is taking place. */ - public function inProgress(string $project_id): JsonResponse { + public function inProgress(string $source, string $project_id): JsonResponse { $requiring = $this->projectBrowserTempStore->get('requiring'); $core_installing = $this->projectBrowserTempStore->get('installing'); $return = ['status' => self::STATUS_IDLE]; + // Prepend the source plugin ID, to create a fully qualified project ID. + $project_id = $source . '/' . $project_id; + if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) { $return['status'] = self::STATUS_REQUIRING_PROJECT; $return['phase'] = $requiring['phase']; @@ -243,18 +248,22 @@ class InstallerController extends ControllerBase { * Updates the 'requiring' state in the temp store. * * @param string $project_id - * The module being required. + * The fully qualified ID of the project being required. * @param string $phase * The require phase in progress. * @param string $stage_id * The stage id. */ - private function setRequiringState(string $project_id, string $phase, string $stage_id = ''): void { - $this->projectBrowserTempStore->set('requiring', [ - 'project_id' => $project_id, - 'phase' => $phase, - 'stage_id' => $stage_id, - ]); + private function setRequiringState(?string $project_id, string $phase, ?string $stage_id): void { + $data = $this->projectBrowserTempStore->get('requiring') ?? []; + if ($project_id) { + $data['project_id'] = $project_id; + } + if ($stage_id) { + $data['stage_id'] = $stage_id; + } + $data['phase'] = $phase; + $this->projectBrowserTempStore->set('requiring', $data); } /** @@ -322,15 +331,16 @@ class InstallerController extends ControllerBase { /** * Begins requiring by creating a stage. * - * @param string $composer_namespace - * The project composer namespace. + * @param string $source + * The source plugin ID. * @param string $project_id - * The project id. + * The ID of the project, as known to the source plugin. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function begin(string $composer_namespace, string $project_id): JsonResponse { + public function begin(string $source, string $project_id): JsonResponse { + $source_id = $source; // @todo Expand to support other plugins in https://drupal.org/i/3312354. $source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL; if ($source === NULL) { @@ -339,6 +349,7 @@ class InstallerController extends ControllerBase { if (!$source->isProjectSafe($project_id)) { return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500); } + $stage_available = $this->installer->isAvailable(); if (!$stage_available) { $requiring_metadata = $this->projectBrowserTempStore->getMetadata('requiring'); @@ -388,7 +399,7 @@ class InstallerController extends ControllerBase { try { $stage_id = $this->installer->create(); - $this->setRequiringState($project_id, 'creating install stage', $stage_id); + $this->setRequiringState($source_id . '/' . $project_id, 'creating install stage', $stage_id); } catch (\Exception $e) { $this->cancelRequire(); @@ -401,49 +412,49 @@ class InstallerController extends ControllerBase { /** * Performs require operations on the stage. * - * @param string $composer_namespace - * The project composer namespace. + * @param string $source + * The source plugin ID. * @param string $project_id - * The project id. - * @param string $stage_id - * ID of stage created in the begin() method. + * The ID of the project, as known to the source plugin. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function require(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { + public function require(string $source, string $project_id): JsonResponse { $requiring = $this->projectBrowserTempStore->get('requiring'); - if (empty($requiring['project_id']) || $requiring['project_id'] !== $project_id) { + if (empty($requiring['project_id']) || $requiring['project_id'] !== $source . '/' . $project_id) { return new JsonResponse([ 'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project_id), ], 500); } - $this->setRequiringState($project_id, 'requiring module', $stage_id); - try { - $this->installer->claim($stage_id)->require(["$composer_namespace/$project_id"]); - } - catch (\Exception $e) { - $this->cancelRequire(); - return $this->errorResponse($e, 'require'); + $this->setRequiringState(NULL, 'requiring module', NULL); + + $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? []; + foreach ($projects as $project) { + if ($project->id === $project_id) { + try { + $this->installer->claim($requiring['stage_id'])->require([ + $project->packageName, + ]); + } + catch (\Exception $e) { + $this->cancelRequire(); + return $this->errorResponse($e, 'require'); + } + } } - return $this->successResponse('require', $stage_id); + return $this->successResponse('require', $requiring['stage_id']); } /** * Performs apply operations on the stage. * - * @param string $composer_namespace - * The project composer namespace. - * @param string $project_id - * The project id. - * @param string $stage_id - * ID of stage created in the begin() method. - * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function apply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { - $this->setRequiringState($project_id, 'applying', $stage_id); + public function apply(): JsonResponse { + $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id']; + $this->setRequiringState(NULL, 'applying', NULL); try { $this->installer->claim($stage_id)->apply(); } @@ -457,18 +468,12 @@ class InstallerController extends ControllerBase { /** * Performs post apply operations on the stage. * - * @param string $composer_namespace - * The project composer namespace. - * @param string $project_id - * The project id. - * @param string $stage_id - * ID of stage created in the begin() method. - * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function postApply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { - $this->setRequiringState($project_id, 'post apply', $stage_id); + public function postApply(): JsonResponse { + $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id']; + $this->setRequiringState(NULL, 'post apply', NULL); try { $this->installer->claim($stage_id)->postApply(); } @@ -481,18 +486,12 @@ class InstallerController extends ControllerBase { /** * Performs destroy operations on the stage. * - * @param string $composer_namespace - * The project composer namespace. - * @param string $project_id - * The project id. - * @param string $stage_id - * ID of stage created in the begin() method. - * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function destroy(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { - $this->setRequiringState($project_id, 'completing', $stage_id); + public function destroy(): JsonResponse { + $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id']; + $this->setRequiringState(NULL, 'completing', NULL); try { $this->installer->claim($stage_id)->destroy(); } @@ -510,25 +509,32 @@ class InstallerController extends ControllerBase { /** * Installs an already downloaded module. * + * @param string $source + * The source plugin ID. * @param string $project_id - * The project machine name. + * The ID of the project, as known to the source plugin. * * @return \Symfony\Component\HttpFoundation\JsonResponse * Status message. */ - public function activateModule(string $project_id): JsonResponse { + public function activate(string $source, string $project_id): JsonResponse { $this->projectBrowserTempStore->set('installing', $project_id); - try { - $this->moduleInstaller->install([$project_id]); - } - catch (\Exception $e) { - $this->resetProgress(); - return $this->errorResponse($e, 'project install'); + + $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? []; + foreach ($projects as $project) { + if ($project->id === $project_id) { + try { + $this->activator->activate($project); + } + catch (\Throwable $e) { + return $this->errorResponse($e, 'project install'); + } + finally { + $this->resetProgress(); + } + } } - $this->projectBrowserTempStore->delete('installing'); - return new JsonResponse([ - 'status' => 0, - ]); + return new JsonResponse(['status' => 0]); } } diff --git a/src/ModuleActivator.php b/src/ModuleActivator.php new file mode 100644 index 0000000000000000000000000000000000000000..08846840d7bbb4bde29a2f70827e82a0490dc4c7 --- /dev/null +++ b/src/ModuleActivator.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\HttpFoundation\Response; + +/** + * An activator for Drupal modules. + */ +final class ModuleActivator implements ActivatorInterface { + + public function __construct( + private readonly ModuleHandlerInterface $moduleHandler, + private readonly ModuleInstallerInterface $moduleInstaller, + ) {} + + /** + * {@inheritdoc} + */ + public function isActive(Project $project): bool { + return $this->moduleHandler->moduleExists($project->machineName); + } + + /** + * {@inheritdoc} + */ + public function supports(Project $project): bool { + // At the moment, Project Browser only supports modules, so all projects can + // be handled by this activator. + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function activate(Project $project): ?Response { + $this->moduleInstaller->install([$project->machineName]); + return NULL; + } + +} diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php index b58136cdfb7c4de9b71a8aa84b4b3ea8398c8c31..80c5fd2cf98adcba160e33e9fef4f4ed88b3f9a9 100644 --- a/src/Plugin/ProjectBrowserSource/DrupalCore.php +++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php @@ -205,7 +205,7 @@ class DrupalCore extends ProjectBrowserSourceBase { author: [ 'name' => 'Drupal Core', ], - composerNamespace: '', + packageName: '', categories: [ [ 'id' => $module->info['package'], diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php index 8995d2b6e333204d4074dbb69552b2036af94c0f..c1cb103a3ca5b7f56f53e71507aa73fc16103263 100644 --- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php +++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php @@ -385,7 +385,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { changed: $project_data['changed'], created: $project_data['created'], author: ['name' => $project_data['author']], - composerNamespace: 'drupal/' . $project_data['field_project_machine_name'], + packageName: 'drupal/' . $project_data['field_project_machine_name'], url: 'https://www.drupal.org/project/' . $project_data['field_project_machine_name'], // Add name property to each category, so it can be rendered. categories: array_map(fn($category) => $categories[$category['id']] ?? '', $project_data['project_data']['taxonomy_vocabulary_3'] ?? []), diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php index 805538e43d74afb06c6540cf780c9465b7728158..32d08f252998747f415e76f1f70d6e35052f14da 100644 --- a/src/ProjectBrowser/Project.php +++ b/src/ProjectBrowser/Project.php @@ -11,6 +11,13 @@ use Drupal\Component\Utility\Xss; */ class Project implements \JsonSerializable { + /** + * The unqualified project ID. + * + * @var string + */ + public readonly string $id; + /** * Constructs a Project object. * @@ -42,8 +49,8 @@ class Project implements \JsonSerializable { * When was the project created last timestamp. * @param array $author * Author of the project in array format. - * @param string $composerNamespace - * Composer namespace of the project. + * @param string $packageName + * The Composer package name of this project, e.g. `drupal/project_browser`. * @param string $url * URL of the project. * @param array $categories @@ -64,6 +71,7 @@ class Project implements \JsonSerializable { * the contents of the "View Commands" popup. * To include a paste-able command that includes a copy button, use this * markup structure: + * * @code * <div class="command-box"> * <input value="THE_COMMAND_TO_BE_COPIED" readonly="" /> @@ -72,6 +80,9 @@ class Project implements \JsonSerializable { * </button> * </div> * @endcode + * @param string $id + * (optional) The unqualified project ID. Cannot contain a slash. Defaults + * to the machine name. */ public function __construct( public array $logo, @@ -88,15 +99,22 @@ class Project implements \JsonSerializable { public int $changed, public int $created, public array $author, - public string $composerNamespace, + public string $packageName, public string $url = '', public array $categories = [], public array $images = [], public array $warnings = [], public string $type = 'module:drupalorg', public string|bool $commands = FALSE, + string $id = '', ) { $this->setSummary($body); + // @see \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage::jsonSerialize() + // @see \Drupal\project_browser\Routing\ProjectBrowserRoutes::routes() + if (str_contains($id, '/')) { + throw new \InvalidArgumentException("Project IDs cannot contain slashes."); + } + $this->id = $id ? $id : $machineName; } /** @@ -144,7 +162,7 @@ class Project implements \JsonSerializable { 'title' => $this->title, 'author' => $this->author, 'warnings' => $this->warnings, - 'composer_namespace' => $this->composerNamespace, + 'package_name' => $this->packageName, // @todo Not used in Svelte. Audit in https://www.drupal.org/i/3309273. 'is_maintained' => $this->isMaintained, 'is_active' => $this->isActive, diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php index 6660df92fbc622096d541026a1758d6fb9676c02..5f520abaa9dfc97a22e9e79f453cb62306e3d9a6 100644 --- a/src/ProjectBrowser/ProjectsResultsPage.php +++ b/src/ProjectBrowser/ProjectsResultsPage.php @@ -7,15 +7,6 @@ namespace Drupal\project_browser\ProjectBrowser; */ class ProjectsResultsPage implements \JsonSerializable { - /** - * Separates the source plugin ID from a project's local ID. - * - * @var string - * - * @see ::jsonSerialize() - */ - public const ID_SEPARATOR = '::'; - /** * Constructor for project browser results page. * @@ -48,7 +39,7 @@ class ProjectsResultsPage implements \JsonSerializable { $map = function (Project $project): object { $serialized = $project->jsonSerialize(); - $serialized->id = $this->pluginId . static::ID_SEPARATOR . $project->machineName; + $serialized->id = $this->pluginId . '/' . $project->id; return $serialized; }; $values['list'] = array_map($map, $values['list']); diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php index 6476426c7f5064cb8bc68047daaa3db5c5a4e493..4bbb15db47c6c675479efcba87ccb1d24af74094 100644 --- a/src/Routing/ProjectBrowserRoutes.php +++ b/src/Routing/ProjectBrowserRoutes.php @@ -46,97 +46,80 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface { return []; } $routes = []; - $stage_id_regex = $machine_name_regex = '[a-zA-Z0-9_-]+'; $routes['project_browser.stage.begin'] = new Route( - '/admin/modules/project_browser/install-begin/{composer_namespace}/{project_id}', + '/admin/modules/project_browser/install-begin/{source}/{project_id}', [ '_controller' => InstallerController::class . '::begin', '_title' => 'Create phase', ], [ '_permission' => 'administer modules', - 'composer_namespace' => $machine_name_regex, - 'project_id' => $machine_name_regex, '_custom_access' => InstallerController::class . '::access', ], ); $routes['project_browser.stage.require'] = new Route( - '/admin/modules/project_browser/install-require/{composer_namespace}/{project_id}/{stage_id}', + '/admin/modules/project_browser/install-require/{source}/{project_id}', [ '_controller' => InstallerController::class . '::require', '_title' => 'Require phase', ], [ '_permission' => 'administer modules', - 'composer_namespace' => $machine_name_regex, - 'project_id' => $machine_name_regex, - 'stage_id' => $stage_id_regex, '_custom_access' => InstallerController::class . '::access', ], ); $routes['project_browser.stage.apply'] = new Route( - '/admin/modules/project_browser/install-apply/{composer_namespace}/{project_id}/{stage_id}', + '/admin/modules/project_browser/install-apply', [ '_controller' => InstallerController::class . '::apply', '_title' => 'Apply phase', ], [ '_permission' => 'administer modules', - 'composer_namespace' => $machine_name_regex, - 'project_id' => $machine_name_regex, - 'stage_id' => $stage_id_regex, '_custom_access' => InstallerController::class . '::access', ], ); $routes['project_browser.stage.post_apply'] = new Route( - '/admin/modules/project_browser/install-post_apply/{composer_namespace}/{project_id}/{stage_id}', + '/admin/modules/project_browser/install-post_apply', [ '_controller' => InstallerController::class . '::postApply', '_title' => 'Post apply phase', ], [ '_permission' => 'administer modules', - 'composer_namespace' => $machine_name_regex, - 'project_id' => $machine_name_regex, - 'stage_id' => $stage_id_regex, '_custom_access' => InstallerController::class . '::access', ], ); $routes['project_browser.stage.destroy'] = new Route( - '/admin/modules/project_browser/install-destroy/{composer_namespace}/{project_id}/{stage_id}', + '/admin/modules/project_browser/install-destroy', [ '_controller' => InstallerController::class . '::destroy', '_title' => 'Destroy phase', ], [ '_permission' => 'administer modules', - 'composer_namespace' => $machine_name_regex, - 'project_id' => $machine_name_regex, - 'stage_id' => $stage_id_regex, '_custom_access' => InstallerController::class . '::access', ], ); - $routes['project_browser.activate.module'] = new Route( - '/admin/modules/project_browser/activate-module/{project_id}', + $routes['project_browser.activate'] = new Route( + '/admin/modules/project_browser/activate/{source}/{project_id}', [ - '_controller' => InstallerController::class . '::activateModule', + '_controller' => InstallerController::class . '::activate', '_title' => 'Install module in core', ], [ '_permission' => 'administer modules', - 'project_id' => $machine_name_regex, '_custom_access' => InstallerController::class . '::access', ], ); $routes['project_browser.module.install_in_progress'] = new Route( - '/admin/modules/project_browser/install_in_progress/{project_id}', + '/admin/modules/project_browser/install_in_progress/{source}/{project_id}', [ '_controller' => InstallerController::class . '::inProgress', '_title' => 'Install in progress', ], [ '_permission' => 'administer modules', - 'project_id' => $machine_name_regex, '_custom_access' => InstallerController::class . '::access', ], ); diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index 71bda0301671ab16e54561ecfb29b3507f95c9d5..87c244f65bde301946546c9c4104f3fb7abfe152 100644 Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map index d9f70bd0776fc52e39e0792a88d8224c3f27e5e8..a75b81a7d650a3977aae6efc31ad4e1ed457f979 100644 Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte index e92b4627c0ffa4bc8cf6b922d060bd4c5c7edcaa..3e9feb7b801a8f8ceefa4ae7bc896448559c25dd 100644 --- a/sveltejs/src/Project/ActionButton.svelte +++ b/sveltejs/src/Project/ActionButton.svelte @@ -67,7 +67,7 @@ * Return is not used, but is a promise due to this being async. */ const showStatus = async (initiate = false) => { - const url = `${ORIGIN_URL}/admin/modules/project_browser/install_in_progress/${project.project_machine_name}`; + const url = `${ORIGIN_URL}/admin/modules/project_browser/install_in_progress/${project.id}`; // /** diff --git a/sveltejs/src/Project/AddInstallButton.svelte b/sveltejs/src/Project/AddInstallButton.svelte index 0e2844cb512dd5754465ee748c4f6b83f76e427b..615395be7caa0e4691f512d946b3e56b8fc7aa30 100644 --- a/sveltejs/src/Project/AddInstallButton.svelte +++ b/sveltejs/src/Project/AddInstallButton.svelte @@ -60,7 +60,7 @@ */ async function installModule() { loading = true; - const url = `${ORIGIN_URL}/admin/modules/project_browser/activate-module/${project.project_machine_name}`; + const url = `${ORIGIN_URL}/admin/modules/project_browser/activate/${project.id}`; const installResponse = await fetch(url); if (!installResponse.ok) { handleError(installResponse); @@ -98,22 +98,21 @@ */ async function doRequests() { loading = true; - const beginInstallUrl = `${ORIGIN_URL}/admin/modules/project_browser/install-begin/${project.composer_namespace}`; + const beginInstallUrl = `${ORIGIN_URL}/admin/modules/project_browser/install-begin/${project.id}`; const beginInstallResponse = await fetch(beginInstallUrl); if (!beginInstallResponse.ok) { await handleError(beginInstallResponse); } else { const beginInstallJson = await beginInstallResponse.json(); - const stageId = beginInstallJson.stage_id; // The process of adding a module is separated into four stages, each // with their own endpoint. When one stage completes, the next one is // requested. const installSteps = [ - `${ORIGIN_URL}/admin/modules/project_browser/install-require/${project.composer_namespace}/${stageId}`, - `${ORIGIN_URL}/admin/modules/project_browser/install-apply/${project.composer_namespace}/${stageId}`, - `${ORIGIN_URL}/admin/modules/project_browser/install-post_apply/${project.composer_namespace}/${stageId}`, - `${ORIGIN_URL}/admin/modules/project_browser/install-destroy/${project.composer_namespace}/${stageId}`, + `${ORIGIN_URL}/admin/modules/project_browser/install-require/${project.id}`, + `${ORIGIN_URL}/admin/modules/project_browser/install-apply`, + `${ORIGIN_URL}/admin/modules/project_browser/install-post_apply`, + `${ORIGIN_URL}/admin/modules/project_browser/install-destroy`, ]; // eslint-disable-next-line no-restricted-syntax,guard-for-in diff --git a/sveltejs/src/popup.js b/sveltejs/src/popup.js index c6d80261a19e651b0e9609242082a4f98179ac79..4c716eda4914d6d90c154cf0ce1704ffa8358575 100644 --- a/sveltejs/src/popup.js +++ b/sveltejs/src/popup.js @@ -111,7 +111,7 @@ export const getCommandsPopupMessage = (project) => { <p>${composerText}</p> <p>${composerExistsText}:</p> <div class="command-box"> - <input value="composer require ${project.composer_namespace}" readonly/> + <input value="composer require ${project.package_name}" readonly/> ${downloadCopyButton} </div> diff --git a/tests/modules/project_browser_test/project_browser_test.services.yml b/tests/modules/project_browser_test/project_browser_test.services.yml index 91638f0b9f875bd77cb90e539eaf35c01dad11a1..b01467dcf2912ebd7a7f00850adc962f207fe296 100644 --- a/tests/modules/project_browser_test/project_browser_test.services.yml +++ b/tests/modules/project_browser_test/project_browser_test.services.yml @@ -8,3 +8,9 @@ services: tags: - { name: http_client_middleware } arguments: ['@module_handler'] + Drupal\project_browser_test\TestActivator: + autowire: true + public: false + decorates: 'Drupal\project_browser\ActivatorInterface' + arguments: + - '@.inner' diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php index 51048153a3644495ef20d316ed5824cf1c62e7f1..d3ef9eb2e515eaef38ee601ad4096973a8ab6029 100644 --- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php +++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php @@ -393,7 +393,7 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase { author: [ 'name' => $related[$uid_info['type']][$uid_info['id']]['name'], ], - composerNamespace: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name, + packageName: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name, categories: $module_categories, images: $project_images, ); diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php new file mode 100644 index 0000000000000000000000000000000000000000..fcc01dc98de6302bebd3fd534cdf4cf5900ec715 --- /dev/null +++ b/tests/modules/project_browser_test/src/TestActivator.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser_test; + +use Drupal\Core\State\StateInterface; +use Drupal\project_browser\ActivatorInterface; +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\HttpFoundation\Response; + +/** + * A test activator that simply logs a state message. + */ +class TestActivator implements ActivatorInterface { + + public function __construct( + private readonly ActivatorInterface $decorated, + private readonly StateInterface $state, + ) {} + + /** + * {@inheritdoc} + */ + public function supports(Project $project): bool { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function isActive(Project $project): bool { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function activate(Project $project): ?Response { + $this->state->set('test activator', "$project->title was activated!"); + return $this->decorated->activate($project); + } + +} diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php index ee5c09b1566142ac21eb16a813cf63070cf28931..bce90909c48ea045f3949b56c18818b6d47f92a4 100644 --- a/tests/src/Functional/InstallerControllerTest.php +++ b/tests/src/Functional/InstallerControllerTest.php @@ -99,7 +99,11 @@ class InstallerControllerTest extends BrowserTestBase { 'field_security_advisory_coverage' => 'covered', 'flag_project_star_user_count' => 0, 'field_project_type' => 'full', - 'project_data' => serialize([]), + 'project_data' => serialize([ + 'body' => [ + 'value' => $this->getRandomGenerator()->paragraphs(1), + ], + ]), 'field_project_machine_name' => 'awesome_module', ]); $query->values([ @@ -115,9 +119,33 @@ class InstallerControllerTest extends BrowserTestBase { 'field_security_advisory_coverage' => 'covered', 'flag_project_star_user_count' => 0, 'field_project_type' => 'full', - 'project_data' => serialize([]), + 'project_data' => serialize([ + 'body' => [ + 'value' => $this->getRandomGenerator()->paragraphs(1), + ], + ]), 'field_project_machine_name' => 'awesome_module', ]); + $query->values([ + 'nid' => 333, + 'title' => 'Drupal core', + 'author' => 'The usual gang of geniuses', + 'created' => 1383917647, + 'changed' => 1663534145, + 'project_usage_total' => 987654321, + 'maintenance_status' => 13028, + 'development_status' => 9988, + 'status' => 1, + 'field_security_advisory_coverage' => 'covered', + 'flag_project_star_user_count' => 0, + 'field_project_type' => 'full', + 'project_data' => serialize([ + 'body' => [ + 'value' => $this->getRandomGenerator()->paragraphs(1), + ], + ]), + 'field_project_machine_name' => 'core', + ]); $query->execute(); $this->initPackageManager(); $this->sharedTempStore = $this->container->get('tempstore.shared'); @@ -133,7 +161,7 @@ class InstallerControllerTest extends BrowserTestBase { */ public function testUiInstallUnavailableIfDisabled() { $this->config('project_browser.admin_settings')->set('allow_ui_install', FALSE)->save(); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(403); $this->assertSession()->pageTextContains('Access denied'); } @@ -145,7 +173,7 @@ class InstallerControllerTest extends BrowserTestBase { */ public function testInstallSecurityRevokedModule() { $this->assertProjectBrowserTempStatus(NULL, NULL); - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/security_revoked_module'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/security_revoked_module'); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"security_revoked_module is not safe to add because its security coverage has been revoked"}', $content); } @@ -160,9 +188,9 @@ class InstallerControllerTest extends BrowserTestBase { // Though core is not available as a choice in project browser, it works // well for the purposes of this test as it's definitely already added // via composer. - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/core'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/core'); $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; - $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/core/$this->stageId"); + $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/core"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', $content); } @@ -174,12 +202,12 @@ class InstallerControllerTest extends BrowserTestBase { */ private function doStart() { $this->assertProjectBrowserTempStatus(NULL, NULL); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; $this->assertSession()->statusCodeEquals(200); $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('awesome_module', 'creating install stage'); + $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage'); } /** @@ -188,10 +216,10 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::require */ private function doRequire() { - $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('awesome_module', 'requiring module'); + $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'requiring module'); } /** @@ -200,10 +228,10 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::apply */ private function doApply() { - $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $this->drupalGet("/admin/modules/project_browser/install-apply"); $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('awesome_module', 'applying'); + $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'applying'); } /** @@ -212,10 +240,10 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::postApply */ private function doPostApply() { - $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId"); + $this->drupalGet("/admin/modules/project_browser/install-post_apply"); $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('awesome_module', 'post apply'); + $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'post apply'); } /** @@ -224,7 +252,7 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::destroy */ private function doDestroy() { - $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId"); + $this->drupalGet("/admin/modules/project_browser/install-destroy"); $expected_output = sprintf('{"phase":"destroy","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); $this->assertInstallNotInProgress('awesome_module'); @@ -250,7 +278,7 @@ class InstallerControllerTest extends BrowserTestBase { $message = t('This is a PreCreate error.'); $result = ValidationResult::createError([$message]); TestSubscriber::setTestResult([$result], PreCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: This is a PreCreate error.\n","phase":"create"}', $contents); } @@ -263,7 +291,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testPreCreateException() { $error = new \Exception('PreCreate did not go well.'); TestSubscriber::setException($error, PreCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PreCreate did not go well.","phase":"create"}', $contents); } @@ -276,7 +304,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testPostCreateException() { $error = new \Exception('PostCreate did not go well.'); TestSubscriber::setException($error, PostCreateEvent::class); - $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PostCreate did not go well.","phase":"create"}', $contents); } @@ -291,7 +319,7 @@ class InstallerControllerTest extends BrowserTestBase { $result = ValidationResult::createError([$message]); $this->doStart(); TestSubscriber::setTestResult([$result], PreRequireEvent::class); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', $contents); } @@ -305,7 +333,7 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PreRequire did not go well.'); TestSubscriber::setException($error, PreRequireEvent::class); $this->doStart(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', $contents); } @@ -319,7 +347,7 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PostRequire did not go well.'); TestSubscriber::setException($error, PostRequireEvent::class); $this->doStart(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', $contents); } @@ -335,7 +363,7 @@ class InstallerControllerTest extends BrowserTestBase { TestSubscriber::setTestResult([$result], PreApplyEvent::class); $this->doStart(); $this->doRequire(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-apply"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: This is a PreApply error.\n","phase":"apply"}', $contents); } @@ -350,7 +378,7 @@ class InstallerControllerTest extends BrowserTestBase { TestSubscriber::setException($error, PreApplyEvent::class); $this->doStart(); $this->doRequire(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-apply"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PreApply did not go well.","phase":"apply"}', $contents); } @@ -366,7 +394,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->doStart(); $this->doRequire(); $this->doApply(); - $contents = $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId"); + $contents = $this->drupalGet("/admin/modules/project_browser/install-post_apply"); $this->assertSession()->statusCodeEquals(500); $this->assertSame('{"message":"StageEventException: PostApply did not go well.","phase":"post apply"}', $contents); } @@ -380,37 +408,37 @@ class InstallerControllerTest extends BrowserTestBase { $this->doStart(); // Check for mid install unlock offer message. - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); - $this->assertInstallInProgress('awesome_module', 'creating install stage'); + $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage'); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); TestTime::setFakeTimeByOffset("+800 seconds"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); $this->doRequire(); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); $this->doApply(); TestTime::setFakeTimeByOffset('+800 seconds'); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertTrue($this->installer->isApplying()); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); TestTime::setFakeTimeByOffset("+55 minutes"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 55 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); // Unlocking the stage becomes possible after 1 hour regardless of source. TestTime::setFakeTimeByOffset("+75 minutes"); - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module'); $this->assertSession()->statusCodeEquals(418); $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 1 hours, 15 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); } @@ -426,7 +454,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->doStart(); // Try beginning another install while one is in progress, but not yet in // the applying stage. - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); @@ -450,7 +478,7 @@ class InstallerControllerTest extends BrowserTestBase { public function testCanBreakStageWithMissingProjectBrowserLock() { $this->doStart(); $this->sharedTempStore->get('project_browser')->delete('requiring'); - $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); $this->assertSession()->statusCodeEquals(418); $this->assertFalse($this->installer->isAvailable()); $this->assertFalse($this->installer->isApplying()); @@ -471,21 +499,25 @@ class InstallerControllerTest extends BrowserTestBase { * * @covers ::activateModule */ - public function testCoreModuleActivate() { + public function testCoreModuleActivate(): void { + $assert_session = $this->assertSession(); + + // Since we are activating a core module, we need the source plugin that + // exposes core modules to be enabled. + $this->config('project_browser.admin_settings') + ->set('enabled_sources', ['drupal_core']) + ->save(); + $this->drupalGet('admin/modules'); - $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable'); - $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable'); - $this->assertFalse($views_checkbox->isChecked()); - $this->assertFalse($views_ui_checkbox->isChecked()); + $assert_session->checkboxNotChecked('edit-modules-views-enable'); + $assert_session->checkboxNotChecked('edit-modules-views-ui-enable'); - $content = $this->drupalGet('admin/modules/project_browser/activate-module/views_ui'); + $content = $this->drupalGet('admin/modules/project_browser/activate/drupal_core/views_ui'); $this->assertSame('{"status":0}', $content); $this->rebuildContainer(); $this->drupalGet('admin/modules'); - $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable'); - $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable'); - $this->assertTrue($views_checkbox->isChecked()); - $this->assertTrue($views_ui_checkbox->isChecked()); + $assert_session->checkboxChecked('edit-modules-views-enable'); + $assert_session->checkboxChecked('edit-modules-views-ui-enable'); } /** @@ -496,9 +528,9 @@ class InstallerControllerTest extends BrowserTestBase { */ protected function assertInstallNotInProgress($module) { $this->assertProjectBrowserTempStatus(NULL, NULL); - $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module"); + $this->drupalGet("/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/$module"); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); - $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag'); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag'); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); } @@ -521,7 +553,7 @@ class InstallerControllerTest extends BrowserTestBase { $this->assertProjectBrowserTempStatus($expect_install, NULL); $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module"); $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $phase), $this->getSession()->getPage()->getContent()); - $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag'); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag'); $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); } @@ -536,6 +568,18 @@ class InstallerControllerTest extends BrowserTestBase { protected function assertProjectBrowserTempStatus($expected_requiring, $expected_installing) { $project_browser_requiring = $this->sharedTempStore->get('project_browser')->get('requiring'); $project_browser_installing = $this->sharedTempStore->get('project_browser')->get('installing'); + if (is_array($expected_installing)) { + ksort($expected_installing); + } + if (is_array($expected_requiring)) { + ksort($expected_requiring); + } + if (is_array($project_browser_installing)) { + ksort($project_browser_installing); + } + if (is_array($project_browser_requiring)) { + ksort($project_browser_requiring); + } $this->assertSame($expected_requiring, $project_browser_requiring); $this->assertSame($expected_installing, $project_browser_installing); } diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index 4d278eaf210a6f17979f0b2c30591f1f89b76dac..466f888e4c2b0621f2d7fc171e5f921bf1d161ff 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\project_browser\FunctionalJavascript; +use Drupal\Core\State\StateInterface; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait; @@ -69,8 +70,12 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->assertSame('Add and Install Cream cheese on a bagel', $download_button->getText()); $download_button->click(); $installed_action = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator", 30000); - $assert_session->waitForText('✓ Cream cheese on a bagel is Installed'); + $this->assertTrue($assert_session->waitForText('✓ Cream cheese on a bagel is Installed')); $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText()); + + // The activator in project_browser_test should have logged a message. + // @see \Drupal\project_browser_test\TestActivator + $this->assertSame('Cream cheese on a bagel was activated!', $this->container->get(StateInterface::class)->get('test activator')); } /** @@ -144,7 +149,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Start install begin. - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); $this->sharedTempStore->get('project_browser')->delete('requiring'); $this->drupalGet('admin/modules/browse'); $this->svelteInitHelper('text', 'Cream cheese on a bagel'); @@ -180,7 +185,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Start install begin. - $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag'); $this->drupalGet('admin/modules/browse'); $this->svelteInitHelper('text', 'Cream cheese on a bagel'); // Try beginning another install while one is in progress, but not yet in @@ -198,7 +203,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $installed_action = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator", 30000); $assert_session->waitForText('✓ Cream cheese on a bagel is Installed'); $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText()); - } }