From 095be78acf49c4fdc1a22a8a0365dbd69c40b741 Mon Sep 17 00:00:00 2001
From: Si Hobbs <38379-sime@users.noreply.drupalcode.org>
Date: Wed, 26 Jun 2024 14:05:08 +0000
Subject: [PATCH] Issue #3365180 by sime, phenaproxima, earthday47,
 chrisfromredfin, pfrilling, andy-blum, fjgarlin: Handle Package Manager
 errors and warnings more elegantly

---
 phpstan.neon                                  |  16 ++++--
 project_browser.libraries.yml                 |   1 +
 src/Controller/BrowserController.php          |  24 +++++---
 src/InstallReadiness.php                      |  27 ++++++---
 src/ProjectBrowserServiceProvider.php         |   2 +
 sveltejs/public/build/bundle.js               | Bin 329173 -> 329350 bytes
 sveltejs/public/build/bundle.js.map           | Bin 302084 -> 302330 bytes
 sveltejs/src/Project/ActionButton.svelte      |  10 +---
 sveltejs/src/Project/AddInstallButton.svelte  |   8 +--
 sveltejs/src/ProjectBrowser.svelte            |  33 ++++++-----
 sveltejs/src/constants.js                     |   4 +-
 .../src/ProjectBrowserTestServiceProvider.php |   9 +++
 .../src/TestInstallReadiness.php              |  53 ++++++++++++++++++
 .../ProjectBrowserInstallerUiTest.php         |  44 ++++++++++++++-
 .../PackageManagerFixtureUtilityTrait.php     |  16 ++++++
 15 files changed, 195 insertions(+), 52 deletions(-)
 create mode 100644 tests/modules/project_browser_test/src/TestInstallReadiness.php

diff --git a/phpstan.neon b/phpstan.neon
index 77763d0c8..47b437cf9 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -10,7 +10,7 @@ parameters:
       # @see https://www.drupal.org/docs/develop/development-tools/phpstan/handling-unsafe-usage-of-new-static#s-ignoring-the-issue
       identifier: new.static
 
-    # @todo: Remove the following ignores when support is dropped for Drupal 10.2, which does not have recipes.
+    # @todo: Remove the following rules when support is dropped for Drupal 10.2, which does not have recipes.
     -
       message: "#^Access to constant COMPOSER_PROJECT_TYPE on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#"
       paths:
@@ -20,9 +20,7 @@ parameters:
       reportUnmatched: false
     -
       message: "#^Call to static method [a-zA-Z]+\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe[a-zA-Z]*\\.$#"
-      paths:
-       - src/RecipeActivator.php
-       - tests/src/Kernel/RecipeActivatorTest.php
+      path: src/RecipeActivator.php
       reportUnmatched: false
     -
       message: "#^Class Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent not found\\.$#"
@@ -32,3 +30,13 @@ parameters:
       message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#"
       path: src/RecipeActivator.php
       reportUnmatched: false
+
+    -
+      message: "#^Call to static method createFromDirectory\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#"
+      path: tests/src/Kernel/RecipeActivatorTest.php
+      reportUnmatched: false
+
+    -
+      message: "#^Call to static method processRecipe\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\RecipeRunner\\.$#"
+      path: tests/src/Kernel/RecipeActivatorTest.php
+      reportUnmatched: false
diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml
index 23f93402b..eb86d09ec 100644
--- a/project_browser.libraries.yml
+++ b/project_browser.libraries.yml
@@ -11,6 +11,7 @@ svelte:
     - core/drupal.debounce
     - core/drupal.dialog
     - core/drupal.announce
+    - core/drupal.message
     - core/once
     - project_browser/project_browser
 
diff --git a/src/Controller/BrowserController.php b/src/Controller/BrowserController.php
index 5874890af..7ddf97e6c 100644
--- a/src/Controller/BrowserController.php
+++ b/src/Controller/BrowserController.php
@@ -32,13 +32,13 @@ class BrowserController extends ControllerBase {
    * @param \Drupal\project_browser\EnabledSourceHandler $enabledSource
    *   The enabled project browser source.
    * @param \Drupal\project_browser\InstallReadiness|null $installReadiness
-   *   The installer service.
+   *   The installer readiness service, if available.
    */
   public function __construct(
     private readonly ModuleExtensionList $moduleList,
     private readonly RequestStack $requestStack,
     private readonly EnabledSourceHandler $enabledSource,
-    private readonly InstallReadiness|NULL $installReadiness,
+    private readonly ?InstallReadiness $installReadiness,
   ) {}
 
   /**
@@ -90,6 +90,19 @@ class BrowserController extends ControllerBase {
       $active_plugins[$source->getPluginId()] = $source->getPluginDefinition()['label'];
     }
 
+    $package_manager = $this->installReadiness?->validatePackageManager() ?? [
+      'errors' => [],
+      'warnings' => [],
+    ];
+    if ($ui_install_enabled) {
+      $package_manager['available'] = array_key_exists('package_manager', $this->moduleList->getAllInstalledInfo());
+    }
+    else {
+      // If installing through the UI is disabled, then as far as Svelte need be
+      // concerned, Package Manager isn't open for business.
+      $package_manager['available'] = FALSE;
+    }
+
     return [
       '#theme' => 'project_browser_main_app',
       '#attached' => [
@@ -99,8 +112,6 @@ class BrowserController extends ControllerBase {
         'drupalSettings' => [
           'project_browser' => [
             'active_plugins' => $active_plugins,
-            'drupal_version' => \Drupal::VERSION,
-            'drupal_core_compatibility' => \Drupal::CORE_COMPATIBILITY,
             'module_path' => $this->moduleHandler()->getModule('project_browser')->getPath(),
             'origin_url' => $request->getSchemeAndHttpHost() . $request->getBaseUrl(),
             'special_ids' => $this->getSpecialIds(),
@@ -110,10 +121,7 @@ class BrowserController extends ControllerBase {
             'development_options' => DevelopmentStatus::asOptions(),
             'default_plugin_id' => $current_source->getPluginId(),
             'current_sources_keys' => $current_sources_keys,
-            'ui_install' => $ui_install_enabled,
-            'stage_available' => $ui_install_enabled ? $this->installReadiness->installerAvailable() : FALSE,
-            'pm_validation' => $ui_install_enabled ? $this->installReadiness->validatePackageManager() : TRUE,
-            'package_manager_available' => array_key_exists('package_manager', $this->moduleList->getAllInstalledInfo()),
+            'package_manager' => $package_manager,
           ],
         ],
       ],
diff --git a/src/InstallReadiness.php b/src/InstallReadiness.php
index 9d76dbde1..c3e6ae040 100644
--- a/src/InstallReadiness.php
+++ b/src/InstallReadiness.php
@@ -4,6 +4,7 @@ namespace Drupal\project_browser;
 
 use Drupal\package_manager\StatusCheckTrait;
 use Drupal\project_browser\ComposerInstaller\Installer;
+use Drupal\system\SystemManager;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
 /**
@@ -21,22 +22,32 @@ class InstallReadiness {
   /**
    * Checks if the environment meets Package Manager install requirements.
    *
-   * @return false|string
-   *   FALSE if no validation errors, otherwise an error message.
+   * @return array[]
+   *   errors - an array of messages with severity 2
+   *   messages - all other messages below severity 2 (warnings)
    */
   public function validatePackageManager() {
-    $text = '';
-    $results = $this->runStatusCheck($this->installer, $this->eventDispatcher);
-    foreach ($results as $result) {
+    $errors = [];
+    $warnings = [];
+    foreach ($this->runStatusCheck($this->installer, $this->eventDispatcher) as $result) {
       $messages = $result->messages;
       $summary = $result->summary;
-
       if ($summary) {
         array_unshift($messages, $summary);
       }
-      $text .= implode("\n", $messages) . "\n";
+      $text = implode("\n", $messages);
+
+      if ($result->severity === SystemManager::REQUIREMENT_ERROR) {
+        $errors[] = $text;
+      }
+      else {
+        $warnings[] = $text;
+      }
     }
-    return $text ?: FALSE;
+    return [
+      'errors' => $errors,
+      'warnings' => $warnings,
+    ];
   }
 
   /**
diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php
index 0f6078cb9..e9b8ddd67 100644
--- a/src/ProjectBrowserServiceProvider.php
+++ b/src/ProjectBrowserServiceProvider.php
@@ -30,6 +30,8 @@ class ProjectBrowserServiceProvider extends ServiceProviderBase {
    */
   public function alter(ContainerBuilder $container) {
     if (array_key_exists('package_manager', $container->getParameter('container.modules'))) {
+      parent::register($container);
+
       $container->register(Installer::class, Installer::class)
         ->setAutowired(TRUE);
 
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 6a1d627c3d6286b09a63eb7e5ac5d1bfdf05ca04..62ff98b2c5c813145a0aa6005f6d239811e13236 100644
GIT binary patch
delta 938
zcmccGEz;I2vLR&2<T*?FCs!_woP1!h(&T?j`Pr?xxD<e3y8mV-nP#hH+pU%{vi-1i
zboO?1ca8UT^aD~sdZ|T4`9;NgIjMQ+B^e5K3I+;lY6<~piW18bGjkG?a#E)+bYu~k
zzV{20-1dd@n3n4~;8cmt7*xA#ZEY0{wildb+QiOcVqs-8z2G;a@^;UwOg~tdOw6`B
z-(o6fVKuX`GB%!G_?uB-`~7=NHXJr4R%V(C)ykP^xE+bp>ggM2GV5;7dCs(uefqx7
zOl;eQ-!rXZVKTGazW+1RS5`)o?OT5`b+RxTZnyu(l*`CuYO#IYf2QL+jHcVmc$hb{
zGMQLz*A`+vC?|>2+uQ#bFrPllWNEbh<Ui)uEKEj5+qW^Zgt0SPZ0F@>sbpp}-`>v0
z@|2m$#B_U}5Q`2ki;=OF3CM%u+qcTIxbm}Dn8Em>`YivLSu9Oqd_7~9e?Surx9eH3
zT$T;cgU1TU!y4#LLJjPi8jSENPb|vI%u9#Gz2@}9FcuRfaFXE!0$>PZH*<Qy4`xw&
z9M+}f7r7=TXK17<*xD&n!wuu)%uOvW2BxvpBE7_v6pe)Gd%{@MSyif2YZIpPg|o;q
zb8^;B5A<ddlg4H*)Tv;b>(On7`-51!Lo!l}QWbI&D;1Jb6*7y9OH+##$}=*PGZa$O
z(o&O46jJib^K$YNQ-GnPkei=Unv+^=r2uxTjzYD(LP=#os+EE|)LwOkT1{(&M{B1)
ebYl{o?k~c`F@45%CjRLfznS^A2Zpn>tOEdiA1&no

delta 778
zcmZqc6}j3ivLR%NfTNF(e|UVTXS}Cha0rky`Qs8LuF}l-%)H`~#GIVT9!q8m2l&Q^
zIr@0IIEHxo`^CEk1^EYol<^ki#+N1LWTqsRWaj5hzP==tE8a28(bLD#$;TC>U~=42
zey;e$vc$}s#H5_m=G>**bC)t|{xHBaPeDyhA;8hu+tJ-M-q+C&NChFx(wT1eg~@O`
z>pZ6AdK%~s2I*A9;=<{FMVRHMzdOUiHT}+gCXVg0XPFw=nM{qgFSx=a%gSVGuzmkc
zrg9cmb0aHb<LQBJjN;P`c$hi2N8e+z;V`nWGSgJ3R?bXQAlAO^51%m|U}rYBvX~z8
znNef=nRiUBEYlafV`ASP@tNrZE2G7B&!0@4ER3exFaKrAWn?ikf+@}U&oq~Z(Q-Q%
z5A$ZA%Z#`07i2ythY>Z~XBse{KFefbx;^b5^J^9+L(}cS%q(H-jE37+aj{e~Ga77H
z;bVEq%w%k_om+@ShnL0B%*q7pwe3OjEUx@4=9Un?#P-d4EPt6<EG%I1r;S+t0ZlZS
zeCM9>_IKti8)Op@;SY`+>~U67S&*8arhp~<6>M#771WDMiZb)k)j=kp2juj3p)4lq
z3P8XK1i7ij#i@DesYQB;DJdG|nRzMs<$5kfr3HyOdL<g_(`SUSsM{%~78T_esp}|I
vE2u-+R#@$>)dZ;lInGK!9imC0cDi5&v+(rg%bED6bIUXFZfA^OX<Y{Zbb9)D

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 4e7d62e19b17da4af044d9b40e3763cb0ff69e5b..f3937ede71587d42c8dff0624832aee1c020be67 100644
GIT binary patch
delta 1420
zcmZ{iNo*Tc9L6>8J)5LiWY-IAX~|1dVh4@fC5JRM4m^*ij%{pe($Y35N~ZRk*o_xr
zXHQes3kM{MCH`<C>8VmA6cA|IvQ-=aA*2FAoN0xG1ga1xL{L>`>_p?3W)3r>`7gik
z``)~EZRh&+oiBYP{1gHz3va-ES$H09%0d^seq3nRR*nn9Yw-9oYO#y-1e0PcERV)n
zLVr99lQy(poA_H;bp^Ioe7saDmdd^yFN{xR=m0%LySp35eaeiI%_-+|T$9_-Z)h8e
zrc>^@cQ>%>hA(;07s6B9)*nRPDlZTS&_f#UK_4H6*a+$(;<*Ba_ufQ%w6{mlugzyn
zn7&M}ZCu_)=GL_rFaFQ*tWqju3*(!?!29EZyE4#i1)GYx4w##>D&1yz<$luLu?<%(
zmSiQB@xtM+1*e_TJTI5^1K=f}l1_WaU{pn(?gNW_X-uSxCamUWYW@X&jC!gTJYr?}
z)qRzBZnX6*ohz7~50bTM$q?4rC~ULn+aeA6RkSy4V8>2oc!|@zGEdKQnk|>7d6~{;
zvZ)N^s>)MSG+mr6<cdmKPmSh_>FFFVAEPx7rmmWUMY`c(gX*D6@cU!v(aO6wS}UJ@
z+YD<hs7bSoqrxw6HH`BR9mKtaGsYn?i1)M_Z+?G7nhN&G+Ug*F>xwX;4g7%<$TbmU
zgZ)9#=ny5wBr(od9F!-B3zlCdmUgo+#MlsgFpRy{)2uJr7t(Iu#nD#05S3u^N4y)p
zKZ~2S6YDsRy{Dw<=y2UmV^oYV7ODGI<*ckC7ksE<rzOHty&<@Cl(@A&@8Lz8HNs*c
zQHH-di36VAi<_*>{|u9Q;l*}xne>M*$Z&987wLbPw7@@h;)S(Wa2qVx$>*BKM!pN|
z$Uk>V3^NuMC0O|v?}MAe*rIhL$dNXbIjs$ikULGV-br>OtLkD*nrn=~45U$flCfl6
zRS%AdvVQZYn5^4aNRu9$|16U;lIa-ipCSj`8YPz2gF!K7j2Pj<-EYt?w_Mw%XT<yw
zCg}}&J`5($U<W+;6@JJ)y~#Kt)mkMc&4l1`O3z>yCyzLFTue1-q9GA}7$Z)bWWuVW
zAoO5IBk@A@<m-v$kRefK(ovH*S0gs-US+ZRLnJ@JPU~!t#TSBbtAn_-1Scg2nmGwy
P&XEa$X})>#^Zx$;VY{%d

delta 1260
zcmaKp+e;Kt9LJe+j!P}+s;OP<G8wpv16oL?i_vdpPwTj=+pey~77UxNvbH<BsBNuM
z_FxoL4+ZsmsOa_&6cwikx<HQxJ@j1GOAtgv&_fV4v%6MaY97v<%lG`gpWpX<e^fd1
zu=3=6>9w~U0)gN@BcdC6Z(qN)zzj%r&LasUVUAD4l1Y5wm9DMc+hYtsK=(qw9_%%E
zI2;V;g{7I4k&Pu2L$QfO+Qbl-QceQ~Ko5W#;CTtoamnR0V%b<C89S7Wi_ct&-ofj4
zC5ILa>ps2Lh=c>mK~uKAWqjsOK=Xr-8$Hl#{ldRZu;QMyNu2m5-LGz53Mtpu%X|NE
zlZ*5!@`2<@JWEIx=Q5A6)B~q)N*mE4Scz}06PLK{BC|D1ausLl$y2FeaakL1%Q9Vy
z(+%Xow#G$|^B;)TC~5jDdSOHfaZdk^iCad6i2o7ug7b-7CY~N%?lt7j&Q94qJ~5Us
zN8AhUEbh#9!zV+e1C2PTK`Cbq**I|@+7cfdOOLq~dFl_44dpH#bS&pv?A+GL^6|z6
z%PHCB?U3Dt!t!yv`dHc^YDY+V4!?P+jB9<gl$ac6e01EAQUOvbnw+k8#Q<W82B3L#
z&?Y&4@zHii&JyGbg0jNg<I%Na>ZnijchQTt=(v9v-&L_H@#7=iN!&VgwX2E_07}Rn
zxIGkH>|I?c77U%B4UT=#*4?a$8=q+RD$>@C!Lw`~&Yh!9;rdQ{Xq~QY?~WEq<SUL?
zFe9qs2k;kbUcSQC&_Pqf-L0(V(py#{UjCrj^4bXay-HDTBY1+KB~>K?5Wx$Bv>G$-
z=?Xkw#!Qy%8s%8AhRN7c!M=*hayHvZS|ehvhaD^>*#WWZ0DHfJ7r3n+v`O`^hCs0@
zN9Q;4WLDoHTbI=q5DWqzz^NhDxXC)pXoZaW0Q&NoD?R|9HPiOvwsUmz)+XyAnrxBQ
z=CzXy*)i~W9}c`t#T{|h;y9{;ZmRhF6syLcuV{(ThgoVhY45{_Id+)riQBzZi;hY5
GtnL@BX?E5C

diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte
index 07a05da91..9c5099115 100644
--- a/sveltejs/src/Project/ActionButton.svelte
+++ b/sveltejs/src/Project/ActionButton.svelte
@@ -1,10 +1,6 @@
 <script>
   import { onMount } from 'svelte';
-  import {
-    ORIGIN_URL,
-    ALLOW_UI_INSTALL,
-    PM_VALIDATION_ERROR,
-  } from '../constants';
+  import { ORIGIN_URL, PACKAGE_MANAGER } from '../constants';
   import Loading from '../Loading.svelte';
   import { openPopup, getCommandsPopupMessage } from '../popup';
   import AddInstallButton from './AddInstallButton.svelte';
@@ -140,7 +136,7 @@
     // should reflect that by adding a progress spinner and disabling actions.
     // The app will check periodically to see if the status has changed and
     // update the UI.
-    if (ALLOW_UI_INSTALL) {
+    if (PACKAGE_MANAGER.available) {
       showStatus();
     }
   });
@@ -155,7 +151,7 @@
     </ProjectStatusIndicator>
   {:else}
     <span>
-      {#if ALLOW_UI_INSTALL && !PM_VALIDATION_ERROR}
+      {#if PACKAGE_MANAGER.available && PACKAGE_MANAGER.errors.length === 0}
         {#if loading}
           <Loading positionAbsolute={true} inline={true} />
           <LoadingEllipsis message={loadingPhase} />
diff --git a/sveltejs/src/Project/AddInstallButton.svelte b/sveltejs/src/Project/AddInstallButton.svelte
index add2b0af9..c3b445d4c 100644
--- a/sveltejs/src/Project/AddInstallButton.svelte
+++ b/sveltejs/src/Project/AddInstallButton.svelte
@@ -1,10 +1,6 @@
 <script>
   import { openPopup } from '../popup';
-  import {
-    ORIGIN_URL,
-    PM_VALIDATION_ERROR,
-    PACKAGE_MANAGER_AVAILABLE,
-  } from '../constants';
+  import { ORIGIN_URL, PACKAGE_MANAGER } from '../constants';
   import ProjectButtonBase from './ProjectButtonBase.svelte';
 
   export let project;
@@ -164,7 +160,7 @@
       downloadProject(true);
     }
   }}
-  disabled={PM_VALIDATION_ERROR && PACKAGE_MANAGER_AVAILABLE}
+  disabled={PACKAGE_MANAGER.errors.length > 0 && PACKAGE_MANAGER.available}
 >
   {alreadyAdded ? Drupal.t('Install') : Drupal.t('Add and Install')}<span
     class="visually-hidden">{project.title}</span
diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte
index 32567ccd9..fd7862117 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -31,10 +31,8 @@
     ORIGIN_URL,
     FULL_MODULE_PATH,
     SORT_OPTIONS,
-    ALLOW_UI_INSTALL,
-    PM_VALIDATION_ERROR,
     ACTIVE_PLUGINS,
-    PACKAGE_MANAGER_AVAILABLE,
+    PACKAGE_MANAGER,
   } from './constants';
   // cspell:ignore tabwise
 
@@ -117,18 +115,27 @@
       $rowsCount = data[$activeTab].totalResults;
 
       if (
-        PACKAGE_MANAGER_AVAILABLE &&
-        PM_VALIDATION_ERROR &&
-        typeof PM_VALIDATION_ERROR === 'string' &&
-        ALLOW_UI_INSTALL
+        PACKAGE_MANAGER.available &&
+        (PACKAGE_MANAGER.errors.length || PACKAGE_MANAGER.warnings.length)
       ) {
         const messenger = new Drupal.Message();
-        messenger.add(
-          Drupal.t('Unable to download modules via the UI: !error', {
-            '!error': PM_VALIDATION_ERROR,
-          }),
-          { type: 'error' },
-        );
+
+        if (PACKAGE_MANAGER.errors.length) {
+          PACKAGE_MANAGER.errors.forEach((e) => {
+            messenger.add(`Unable to download modules via the UI: ${e}`, {
+              type: 'error',
+            });
+          });
+        }
+
+        if (PACKAGE_MANAGER.warnings.length) {
+          PACKAGE_MANAGER.warnings.forEach((e) => {
+            messenger.add(
+              `There may be issues which effect downloading modules: ${e}`,
+              { type: 'warning' },
+            );
+          });
+        }
       }
     } else {
       rows = [];
diff --git a/sveltejs/src/constants.js b/sveltejs/src/constants.js
index efc6460c8..67326af8d 100644
--- a/sveltejs/src/constants.js
+++ b/sveltejs/src/constants.js
@@ -16,10 +16,8 @@ export const CURRENT_SOURCES_KEYS =
   drupalSettings.project_browser.current_sources_keys;
 export const ORIGIN_URL = drupalSettings.project_browser.origin_url;
 export const FULL_MODULE_PATH = `${ORIGIN_URL}/${drupalSettings.project_browser.module_path}`;
-export const ALLOW_UI_INSTALL = drupalSettings.project_browser.ui_install;
 export const DARK_COLOR_SCHEME =
   matchMedia('(forced-colors: active)').matches &&
   matchMedia('(prefers-color-scheme: dark)').matches;
-export const PM_VALIDATION_ERROR = drupalSettings.project_browser.pm_validation;
 export const ACTIVE_PLUGINS = drupalSettings.project_browser.active_plugins;
-export const PACKAGE_MANAGER_AVAILABLE = drupalSettings.project_browser.package_manager_available;
+export const PACKAGE_MANAGER = drupalSettings.project_browser.package_manager;
diff --git a/tests/modules/project_browser_test/src/ProjectBrowserTestServiceProvider.php b/tests/modules/project_browser_test/src/ProjectBrowserTestServiceProvider.php
index 20163bade..bb05089c6 100644
--- a/tests/modules/project_browser_test/src/ProjectBrowserTestServiceProvider.php
+++ b/tests/modules/project_browser_test/src/ProjectBrowserTestServiceProvider.php
@@ -4,6 +4,7 @@ namespace Drupal\project_browser_test;
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\project_browser\InstallReadiness;
 
 /**
  * Overrides the module installer service.
@@ -17,6 +18,14 @@ class ProjectBrowserTestServiceProvider extends ServiceProviderBase {
     $definition = $container->getDefinition('module_installer');
     $definition->setClass('Drupal\project_browser_test\Extension\TestModuleInstaller')
       ->setLazy(FALSE);
+
+    // The InstallReadiness service is defined by ProjectBrowserServiceProvider
+    // if Package Manager is installed.
+    if ($container->hasDefinition(InstallReadiness::class)) {
+      $container->register(TestInstallReadiness::class, TestInstallReadiness::class)
+        ->setAutowired(TRUE)
+        ->addTag('event_subscriber');
+    }
   }
 
 }
diff --git a/tests/modules/project_browser_test/src/TestInstallReadiness.php b/tests/modules/project_browser_test/src/TestInstallReadiness.php
new file mode 100644
index 000000000..8dcacb2da
--- /dev/null
+++ b/tests/modules/project_browser_test/src/TestInstallReadiness.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\project_browser_test;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Event\StatusCheckEvent;
+use Drupal\system\SystemManager;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Simulates status check results for Project Browser's installer.
+ */
+class TestInstallReadiness implements EventSubscriberInterface {
+
+  public function __construct(private readonly StateInterface $state) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      StatusCheckEvent::class => 'onStatusCheck',
+    ];
+  }
+
+  /**
+   * Sets simulated errors or warnings during a Project Browser status check.
+   *
+   * @param \Drupal\package_manager\Event\StatusCheckEvent $event
+   *   The event object.
+   */
+  public function onStatusCheck(StatusCheckEvent $event): void {
+    // We don't care about anything except Project Browser's installer.
+    if ($event->stage->getType() !== 'project_browser.installer') {
+      return;
+    }
+
+    $severity = $this->state->get('project_browser_test.simulated_result_severity');
+
+    if ($severity === SystemManager::REQUIREMENT_ERROR) {
+      $event->addError([
+        t('Simulate an error message for the project browser.'),
+      ]);
+    }
+    elseif ($severity === SystemManager::REQUIREMENT_WARNING) {
+      $event->addWarning([
+        t('Simulate a warning message for the project browser.'),
+      ]);
+    }
+  }
+
+}
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index e0bc77d70..6f7d49f23 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -9,6 +9,7 @@ use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\project_browser\EnabledSourceHandler;
+use Drupal\system\SystemManager;
 use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait;
 
 /**
@@ -33,9 +34,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * {@inheritdoc}
    */
   protected static $modules = [
-    'package_manager_bypass',
-    'package_manager',
-    'package_manager_test_validation',
     'project_browser',
     'project_browser_test',
   ];
@@ -251,6 +249,46 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText());
   }
 
+  /**
+   * Confirm that a status check error prevents download and install.
+   */
+  public function testPackageManagerErrorPreventsDownload(): void {
+    // @see \Drupal\project_browser_test\TestInstallReadiness
+    $this->container->get(StateInterface::class)
+      ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_ERROR);
+
+    $assert_session = $this->assertSession();
+    $this->drupalGet('admin/modules/browse');
+    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
+    $assert_session->statusMessageContains("Simulate an error message for the project browser.", 'error');
+    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
+    $download_button_text = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button")
+      ?->getText();
+    $this->assertSame('View Commands for Cream cheese on a bagel', $download_button_text);
+  }
+
+  /**
+   * Confirm that a status check warning allows download and install.
+   */
+  public function testPackageManagerWarningAllowsDownloadInstall(): void {
+    // @see \Drupal\project_browser_test\TestInstallReadiness
+    $this->container->get(StateInterface::class)
+      ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_WARNING);
+
+    $assert_session = $this->assertSession();
+    $this->drupalGet('admin/modules/browse');
+    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
+    $assert_session->statusMessageContains("Simulate a warning message for the project browser.", 'warning');
+    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
+    $download_button = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button");
+    $this->assertNotEmpty($download_button);
+    $this->assertSame('Add and Install Cream cheese on a bagel', $download_button->getText());
+    $download_button->click();
+    $installed = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator")
+      ?->waitFor(10, fn (NodeElement $button) => $button->getText() === '✓ Cream cheese on a bagel is Installed');
+    $this->assertTrue($installed);
+  }
+
   /**
    * Finds a project, from among the enabled sources, that can be installed.
    *
diff --git a/tests/src/Traits/PackageManagerFixtureUtilityTrait.php b/tests/src/Traits/PackageManagerFixtureUtilityTrait.php
index 7c10dfeef..ed77fa623 100644
--- a/tests/src/Traits/PackageManagerFixtureUtilityTrait.php
+++ b/tests/src/Traits/PackageManagerFixtureUtilityTrait.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\Traits;
 
+use Drupal\Core\Extension\MissingDependencyException;
 use Drupal\package_manager\PathLocator;
 use Symfony\Component\Filesystem\Filesystem;
 
@@ -22,6 +23,21 @@ trait PackageManagerFixtureUtilityTrait {
    * Initializes Package Manager.
    */
   protected function initPackageManager(): void {
+    // @todo Move back to static::$modules in https://www.drupal.org/i/3349193.
+    $modules = [
+      'package_manager_bypass',
+      'package_manager',
+      'package_manager_test_validation',
+    ];
+    try {
+      $this->container->get('module_installer')->install($modules);
+      // The container was rebuilt by the ModuleInstaller.
+      $this->container = \Drupal::getContainer();
+    }
+    catch (MissingDependencyException $e) {
+      $this->markTestSkipped($e->getMessage());
+    }
+
     $pm_path = $this->container->get('extension.list.module')->getPath('package_manager');
     $this->useFixtureDirectoryAsActive($pm_path . '/tests/fixtures/fake_site');
   }
-- 
GitLab