diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 2e1560123835489e3d8b2e0fbcb7465554334375..9673f0e00cff82125f92214712be98ee9aa1cc0d 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
-use Behat\Mink\Element\NodeElement;
 use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
@@ -78,18 +77,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
   public function testSingleModuleAddAndInstall(): void {
     TestActivator::handle('drupal/cream_cheese');
 
-    $assert_session = $this->assertSession();
-
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $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.pb__action_button");
-    $this->assertNotEmpty($download_button);
-    $this->assertSame('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);
-    $this->assertTrue($assert_session->waitForText('Cream cheese on a bagel is Installed'));
-    $this->assertSame('Cream cheese on a bagel is Installed', $installed_action->getText());
+    $this->installProject('Cream cheese on a bagel');
 
     // The activator in project_browser_test should have logged a message.
     // @see \Drupal\project_browser_test\TestActivator
@@ -107,12 +96,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $assert_session = $this->assertSession();
 
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Pinky and the Brain');
-    $pinky_brain_selector = '#project-browser .pb-layout__main ul > li:nth-child(2)';
-    $action_button = $assert_session->waitForElementVisible('css', "$pinky_brain_selector button.pb__action_button");
-    $this->assertNotEmpty($action_button);
-    $this->assertSame('Install Pinky and the Brain', $action_button->getText());
-    $action_button->click();
+    $this->waitForProject('Pinky and the Brain')
+      ->pressButton('Install Pinky and the Brain');
     $popup = $assert_session->waitForElementVisible('css', '.project-browser-popup');
     $this->assertNotEmpty($popup);
     // The Pinky and the Brain module doesn't actually exist in the filesystem,
@@ -140,28 +125,18 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->inputSearchField('image', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")->click();
 
-    $assert_installed = function (NodeElement $card): void {
-      $installed = $card->waitFor(30, function () use ($card): bool {
-        return $card->has('css', '.project_status-indicator:contains("Installed")');
-      });
-      $this->assertTrue($installed);
-    };
-
     // Apply a recipe that ships with core.
-    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")');
-    $this->assertNotEmpty($card);
-    $assert_session->buttonExists('Install', $card)->press();
-    $assert_installed($card);
+    $card = $this->waitForProject('Image media type');
+    $card->pressButton('Install');
+    $this->waitForProjectToBeInstalled($card);
 
     // If we reload, the installation status should be remembered.
     $this->getSession()->reload();
     $this->inputSearchField('image', TRUE);
-    $submit_button = $assert_session->waitForElementVisible('css', ".search__search-submit");
-    $this->assertNotEmpty($submit_button);
-    $submit_button->click();
-    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")');
-    $this->assertNotEmpty($card);
-    $assert_installed($card);
+    $assert_session->waitForElementVisible('css', ".search__search-submit")
+      ?->click();
+    $card = $this->waitForProject('Image media type');
+    $this->waitForProjectToBeInstalled($card);
 
     // Apply a recipe that requires user input.
     // @todo Remove this check in https://www.drupal.org/i/3494848.
@@ -170,18 +145,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     }
     $this->inputSearchField('test', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")->click();
-    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Test Recipe")');
-    $this->assertNotEmpty($card);
-    $assert_session->buttonExists('Install', $card)->press();
+    $this->waitForProject('Test Recipe')->pressButton('Install');
     $field = $assert_session->waitForField('test_recipe[new_name]');
     $this->assertNotEmpty($field);
     $field->setValue('Y halo thar!');
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
     $this->inputSearchField('test', TRUE);
-    $assert_session->waitForElementVisible('css', ".search__search-submit")->click();
-    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Test Recipe")');
-    $assert_installed($card);
+    $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
+    $this->waitForProjectToBeInstalled('Test Recipe');
     $this->assertSame('Y halo thar!', $this->config('system.site')->get('name'));
   }
 
@@ -189,27 +161,16 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * Tests install UI not available if not enabled.
    */
   public function testAllowUiInstall(): void {
-    $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
-
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Pinky and the Brain');
-
-    $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.pb__action_button");
-    $this->assertNotEmpty($download_button);
-    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
-    $this->drupalGet('/admin/config/development/project_browser');
-    $page->find('css', '#edit-allow-ui-install')?->click();
-    $assert_session->checkboxNotChecked('edit-allow-ui-install');
-    $this->submitForm([], 'Save');
-    $this->assertTrue($assert_session->waitForText('The configuration options have been saved.'));
 
-    $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $action_button = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button.pb__action_button");
-    $this->assertNotEmpty($action_button);
-    $this->assertSame('View Commands for Cream cheese on a bagel', $action_button->getText());
+    $cream_cheese = $this->waitForProject('Cream cheese on a bagel');
+    $this->assertTrue($cream_cheese->hasButton('Install Cream cheese on a bagel'));
+    $this->config('project_browser.admin_settings')
+      ->set('allow_ui_install', FALSE)
+      ->save();
+    $this->getSession()->reload();
+    $cream_cheese = $this->waitForProject('Cream cheese on a bagel');
+    $this->assertTrue($cream_cheese->hasButton('View Commands for Cream cheese on a bagel'));
   }
 
   /**
@@ -219,7 +180,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    */
   public function testCanBreakStageWithMissingProjectBrowserLock(): void {
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
 
     // Start install begin.
     $this->drupalGet('admin/modules/project_browser/install-begin', [
@@ -227,25 +187,17 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     ]);
     $this->installState->deleteAll();
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install while one is in progress, but not yet in
     // the applying stage.
-    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
-    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
-    $cream_cheese_button?->click();
+    $this->waitForProject('Cream cheese on a bagel')
+      ->pressButton('Install Cream cheese on a bagel');
 
     $this->assertTrue($assert_session->waitForText('The process for adding projects is locked, but that lock has expired. Use unlock link to unlock the process and try to add the project again.'));
 
     // Click Unlock Install Stage link.
     $this->clickWithWait('#ui-id-1 > p > a');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install after breaking lock.
-    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
-    $cream_cheese_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->assertSame('Cream cheese on a bagel is Installed', $installed_action->getText());
-
+    $this->installProject('Cream cheese on a bagel');
   }
 
   /**
@@ -257,29 +209,21 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    */
   public function testCanBreakLock(): void {
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
 
     // Start install begin.
     $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
     ]);
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install while one is in progress, but not yet in
     // the applying stage.
-    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
-    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
-    $cream_cheese_button?->click();
+    $this->waitForProject('Cream cheese on a bagel')
+      ->pressButton('Install Cream cheese on a bagel');
     $this->assertTrue($assert_session->waitForText('The process for adding projects is locked, but that lock has expired. Use unlock link to unlock the process and try to add the project again.'));
     // Click Unlock Install Stage link.
     $this->clickWithWait('#ui-id-1 > p > a');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install after breaking lock.
-    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
-    $cream_cheese_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->assertSame('Cream cheese on a bagel is Installed', $installed_action->getText());
+    $this->installProject('Cream cheese on a bagel');
   }
 
   /**
@@ -292,17 +236,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     $assert_session = $this->assertSession();
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $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.pb__action_button");
-    $this->assertNotEmpty($download_button);
-    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
-    $download_button->click();
-    $this->assertSame('Installing', $download_button->getText());
+    $cream_cheese = $this->waitForProject('Cream cheese on a bagel');
+    $cream_cheese->pressButton('Install Cream cheese on a bagel');
     $this->assertTrue($assert_session->waitForText('Simulate an error message for the project browser.'));
-    $download_button_text = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button.pb__action_button")
-      ?->getText();
-    $this->assertSame('Install Cream cheese on a bagel', $download_button_text);
+    $this->assertTrue($cream_cheese->hasButton('Install Cream cheese on a bagel'));
   }
 
   /**
@@ -313,23 +250,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->container->get(StateInterface::class)
       ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_WARNING);
 
-    $assert_session = $this->assertSession();
-
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $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.pb__action_button");
-    $this->assertNotEmpty($download_button);
-    $this->assertSame('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);
-    $this->assertNotEmpty($installed_action);
-    $installed_action = $installed_action->waitFor(30, function ($button) {
-      return $button->getText() === 'Cream cheese on a bagel is Installed';
-    });
-    $this->assertTrue($installed_action);
+    $this->installProject('Cream cheese on a bagel');
     $this->drupalGet('admin/reports/dblog');
-    $assert_session->pageTextContains('Simulate a warning message for the project browser.');
+    $this->assertSession()->pageTextContains('Simulate a warning message for the project browser.');
   }
 
   /**
@@ -341,15 +265,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $page = $this->getSession()->getPage();
     $assert_session = $this->assertSession();
     $this->drupalGet('project-browser/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $this->svelteInitHelper('text', 'Kangaroo');
-
-    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
-    $select_button1 = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button.pb__action_button");
-    $this->assertNotEmpty($select_button1);
-    $this->assertSame('Select Cream cheese on a bagel', $select_button1?->getText());
-    $select_button1?->click();
-    $was_selected = $select_button1->waitFor(10, fn ($button) => $button->getText() === 'Deselect Cream cheese on a bagel');
+
+    $cream_cheese = $this->waitForProject('Cream cheese on a bagel');
+    $cream_cheese->pressButton('Select Cream cheese on a bagel');
+    $was_selected = $cream_cheese->waitFor(10, fn ($card) => $card->hasButton('Deselect Cream cheese on a bagel'));
     $this->assertTrue($was_selected);
 
     $dancing_queen_button = $page->find('css', '#project-browser .pb-layout__main ul > li:nth-child(3) button');
@@ -357,14 +276,9 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     $this->assertNotEmpty($assert_session->waitForButton('Install selected projects'));
 
-    $kangaroo_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(4)';
-    $select_button2 = $assert_session->waitForElementVisible('css', "$kangaroo_module_selector button.pb__action_button");
-    $this->assertNotEmpty($select_button2);
-    $this->assertSame('Select Kangaroo', $select_button2?->getText());
-    $select_button2?->click();
-    $was_deselected = $select_button2->waitFor(10, function ($button) {
-      return $button->getText() === 'Deselect Kangaroo';
-    });
+    $kangaroo = $this->waitForProject('Kangaroo');
+    $kangaroo->pressButton('Select Kangaroo');
+    $was_deselected = $kangaroo->waitFor(10, fn ($card) => $card->hasButton('Deselect Kangaroo'));
     $this->assertTrue($was_deselected);
     // Select button gets disabled on reaching maximum limit.
     $assert_session->elementAttributeExists('css', '#project-browser .pb-layout__main ul > li:nth-child(3) button.pb__action_button', 'disabled');
@@ -372,22 +286,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->assertNotEmpty($assert_session->waitForButton('Install selected projects'));
     $page->pressButton('Install selected projects');
 
-    $installed_action = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator", 30000);
-    $installed_action = $installed_action->waitFor(30, function ($button) {
-      return $button->getText() === 'Cream cheese on a bagel is Installed';
-    });
-    $this->assertTrue($installed_action);
-
-    $installed_action = $assert_session->waitForElementVisible('css', "$kangaroo_module_selector .project_status-indicator", 30000);
-    $installed_action = $installed_action->waitFor(30, function ($button) {
-      return $button->getText() === 'Kangaroo is Installed';
-    });
-    $this->assertTrue($installed_action);
+    $this->waitForProjectToBeInstalled($cream_cheese);
+    $this->waitForProjectToBeInstalled($kangaroo);
 
     // The activator in project_browser_test should have logged a message.
     // @see \Drupal\project_browser_test\TestActivator
-    $this->assertContains('Cream cheese on a bagel was activated!', $this->container->get(StateInterface::class)->get('test activator'));
-    $this->assertContains('Kangaroo was activated!', $this->container->get(StateInterface::class)->get('test activator'));
+    $activated = $this->container->get(StateInterface::class)
+      ->get('test activator');
+    $this->assertContains('Cream cheese on a bagel was activated!', $activated);
+    $this->assertContains('Kangaroo was activated!', $activated);
   }
 
   /**
@@ -398,15 +305,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->container->get('module_installer')->install(['project_browser_devel'], TRUE);
     $this->drupalGet('project-browser/project_browser_test_mock');
 
-    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
-    $select_button1 = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button.pb__action_button");
-    $select_button1->click();
+    $this->waitForProject('Cream cheese on a bagel')
+      ->pressButton('Select Cream cheese on a bagel');
     $this->assertNotEmpty($assert_session->waitForButton('Install selected projects'));
 
-    $random_data = '#project-browser .pb-layout__main ul > li:nth-child(2)';
-    $select_button2 = $assert_session->waitForElementVisible('css', "$random_data button.pb__action_button");
-    $this->assertNotEmpty($select_button2);
-    $select_button2?->click();
+    $projects = $this->getSession()
+      ->getPage()
+      ->findAll('css', '.pb-project');
+    $this->assertGreaterThanOrEqual(2, count($projects));
+    $projects[1]->find('css', '.pb__action_button')?->press();
     $this->assertNotEmpty($assert_session->waitForButton('Install selected projects'));
   }
 
@@ -421,12 +328,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     ]);
     $this->installState->deleteAll();
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $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.pb__action_button");
-    $this->assertNotEmpty($download_button);
-    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
-    $download_button->click();
+    $this->waitForProject('Cream cheese on a bagel')
+      ->pressButton('Install Cream cheese on a bagel');
     $unlock_url = $assert_session->waitForElementVisible('css', "#unlock-link")->getAttribute('href');
     $path_string = parse_url($unlock_url, PHP_URL_PATH);
     $this->assertIsString($path_string);
@@ -445,7 +348,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
   public function testSelectDeselectToggleInModal(): void {
     $assert_session = $this->assertSession();
     $this->drupalGet('project-browser/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Helvetica');
     $assert_session->waitForButton('Helvetica')?->click();
     // Click select button in modal.
     $assert_session->elementExists('css', '.pb-detail-modal__sidebar_element button.pb__action_button')->click();
@@ -456,18 +358,13 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     // Close the modal.
     $assert_session->waitForButton('Close')?->click();
     $assert_session->elementNotExists('xpath', '//span[contains(@class, "ui-dialog-title") and text()="Helvetica"]');
-    $select_button = $assert_session->waitForElementVisible('css', "#project-browser .pb-layout__main ul > li:nth-child(7) button.pb__action_button");
-    $this->assertNotEmpty($select_button);
-    // Asserts that the project is selected.
-    $was_selected = $select_button->waitFor(10, fn ($button) => $button->getText() === 'Deselect Helvetica');
-    $this->assertTrue($was_selected);
+    $this->assertTrue($this->waitForProject('Helvetica')->hasButton('Deselect Helvetica'));
   }
 
   /**
    * Tests that the install state does not change on error.
    */
   public function testInstallStatusUnchangedOnError(): void {
-    $assert_session = $this->assertSession();
     $page = $this->getSession()->getPage();
 
     // Start install begin.
@@ -475,19 +372,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
       'query' => ['source' => 'project_browser_test_mock'],
     ]);
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-    $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install while one is in progress, but not yet in
     // the applying stage.
-    $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
-    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
-    $cream_cheese_button?->click();
+    $this->waitForProject('Cream cheese on a bagel')
+      ->pressButton('Install Cream cheese on a bagel');
     // Close the dialog to assert the state of install button.
     $page->find('css', '.ui-dialog-titlebar-close')?->click();
-    $download_button = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector button.pb__action_button");
-    $this->assertNotEmpty($download_button);
     // Assertion that the install state does not change.
-    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
-
+    $cream_cheese = $this->waitForProject('Cream cheese on a bagel');
+    $this->assertTrue($cream_cheese->hasButton('Install Cream cheese on a bagel'));
   }
 
 }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
index b17b3420b452b1736449d5584b4df2a884d3afe0..b7f40a12032cdb796a5cb822c99024bc67653d96 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
@@ -11,6 +11,78 @@ use Behat\Mink\Element\NodeElement;
  */
 trait ProjectBrowserUiTestTrait {
 
+  /**
+   * Installs a specific project.
+   *
+   * @param \Behat\Mink\Element\NodeElement|string $card
+   *   The project's card element, or its exact name.
+   * @param int $timeout
+   *   (optional) How many seconds to wait for the installation to finish.
+   *   Defaults to 30.
+   *
+   * @see ::waitForProject()
+   * @see ::waitForProjectToBeInstalled()
+   */
+  protected function installProject(NodeElement|string $card, int $timeout = 30): void {
+    if (is_string($card)) {
+      $card = $this->waitForProject($card);
+    }
+    $name = $card->find('css', '.pb-project__title')?->getText();
+    $this->assertNotEmpty($name);
+    $card->pressButton("Install $name");
+    $this->waitForProjectToBeInstalled($card, $timeout);
+  }
+
+  /**
+   * Waits for a specific project to be installed.
+   *
+   * @param \Behat\Mink\Element\NodeElement|string $card
+   *   The project's card element, or its exact name.
+   * @param int $timeout
+   *   (optional) How many seconds to wait for the installation to finish.
+   *   Defaults to 30.
+   *
+   * @see ::waitForProject()
+   */
+  protected function waitForProjectToBeInstalled(NodeElement|string $card, int $timeout = 30): void {
+    if (is_string($card)) {
+      $card = $this->waitForProject($card);
+    }
+    $name = $card->find('css', '.pb-project__title')?->getText();
+    $this->assertNotEmpty($name);
+
+    $indicator = $card->waitFor(
+      $timeout,
+      fn (NodeElement $card): ?NodeElement => $card->find('css', '.project_status-indicator'),
+    );
+    $was_installed = $indicator?->waitFor(
+      $timeout,
+      fn (NodeElement $indicator) => $indicator->getText() === "$name is Installed",
+    );
+    $this->assertTrue($was_installed, "$name was not installed after waiting $timeout seconds.");
+  }
+
+  /**
+   * Waits for a project card to appear, and returns it.
+   *
+   * @param string $name
+   *   The full human-readable name of the project as it appears in the UI.
+   * @param int $timeout
+   *   (optional) How many seconds to wait for the project to appear. Defaults
+   *   to 10.
+   *
+   * @return \Behat\Mink\Element\NodeElement
+   *   The project card element.
+   */
+  protected function waitForProject(string $name, int $timeout = 10): NodeElement {
+    $element = $this->assertSession()
+      ->waitForElementVisible('css', ".pb-project__title:contains('$name')", $timeout * 1000)
+      ?->find('xpath', '..')
+      ?->find('xpath', '..');
+    $this->assertNotEmpty($element);
+    return $element;
+  }
+
   /**
    * Asserts that a table row element was dragged to another spot in the table.
    *