From 88b8523fda5fa4e09bcd3a62fe9b73e4136401cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 10:17:53 -0500
Subject: [PATCH 01/12] Use xxh64 for top-level query cache

---
 project_browser.services.yml |  2 +-
 src/EnabledSourceHandler.php | 40 ++++++++++++++++++++++++++++--------
 2 files changed, 32 insertions(+), 10 deletions(-)

diff --git a/project_browser.services.yml b/project_browser.services.yml
index 4e2e42215..3b18bdd9c 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -9,7 +9,7 @@ services:
     parent: default_plugin_manager
   Drupal\project_browser\EnabledSourceHandler:
     arguments:
-      $keyValueFactory: '@keyvalue'
+      $keyValueFactory: '@keyvalue.expirable'
     calls:
       - [setLogger, ['@logger.channel.project_browser']]
     tags:
diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 1f3ad4119..8b83e108b 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -2,12 +2,13 @@
 
 namespace Drupal\project_browser;
 
+use Composer\InstalledVersions;
 use Drupal\Component\Serialization\Json;
 use Drupal\Core\Config\ConfigCrudEvent;
 use Drupal\Core\Config\ConfigEvents;
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
-use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
@@ -25,7 +26,7 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
   public function __construct(
     private readonly ConfigFactoryInterface $configFactory,
     private readonly ProjectBrowserSourceManager $pluginManager,
-    private readonly KeyValueFactoryInterface $keyValueFactory,
+    private readonly KeyValueExpirableFactoryInterface $keyValueFactory,
   ) {}
 
   /**
@@ -34,10 +35,10 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
    * @param string $source_id
    *   The ID of a source plugin.
    *
-   * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+   * @return \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
    *   A key-value store for the specified source plugin.
    */
-  private function keyValue(string $source_id): KeyValueStoreInterface {
+  private function keyValue(string $source_id): KeyValueStoreExpirableInterface {
     return $this->keyValueFactory->get("project_browser:$source_id");
   }
 
@@ -109,7 +110,7 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
    */
   public function getProjects(string $source_id, array $query = []): ProjectsResultsPage {
     // Cache only exact query, down to the page number.
-    $cache_key = 'query:' . md5(Json::encode($query));
+    $cache_key = $this->getQueryCacheKey($query);
 
     $storage = $this->keyValue($source_id);
 
@@ -128,19 +129,40 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
       $storage->setIfNotExists($project->id, $project);
     }
     // If there were no query errors, store the results as a set of arguments
-    // to ProjectsResultsPage.
+    // to ProjectsResultsPage. These cached results automatically expire daily.
     if (empty($results->error)) {
-      $storage->set($cache_key, [
+      $storage->setWithExpire($cache_key, [
         $results->totalResults,
         array_column($results->list, 'id'),
         $results->pluginLabel,
         $source_id,
         $results->error,
-      ]);
+      ], 86400);
     }
     return $results;
   }
 
+  /**
+   * Generates a cache key for a specific query.
+   *
+   * @param array $query
+   *   The query.
+   *
+   * @return string
+   *   A cache key for the given query.
+   */
+  private function getQueryCacheKey(array $query): string {
+    // Include a quick hash of the top-level `composer.lock` file in the hash,
+    // so that sources which base their queries on the state of the local site
+    // will be refreshed when the local site changes.
+    ['install_path' => $project_root] = InstalledVersions::getRootPackage();
+    $lock_file = $project_root . DIRECTORY_SEPARATOR . 'composer.lock';
+    $lock_file_hash = file_exists($lock_file)
+      ? hash_file('xxh64', $lock_file)
+      : '';
+    return md5(Json::encode($query) . $lock_file_hash);
+  }
+
   /**
    * Queries the specified source.
    *
-- 
GitLab


From a1bd72dde4eab7c53499510a0c6fd95f3ec07f20 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 10:31:54 -0500
Subject: [PATCH 02/12] Fix EnabledSourceHandlerTest

---
 src/EnabledSourceHandler.php                      |  2 +-
 tests/src/Functional/EnabledSourceHandlerTest.php | 15 +++++++++------
 2 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 8b83e108b..e4b70c6aa 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -160,7 +160,7 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
     $lock_file_hash = file_exists($lock_file)
       ? hash_file('xxh64', $lock_file)
       : '';
-    return md5(Json::encode($query) . $lock_file_hash);
+    return 'query:' . md5(Json::encode($query) . $lock_file_hash);
   }
 
   /**
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index f0b9ce8b4..5dac6b3e8 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -66,20 +66,23 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     /** @var \Drupal\project_browser\EnabledSourceHandler $handler */
     $handler = $this->container->get(EnabledSourceHandler::class);
 
-    $handler->getProjects('project_browser_test_mock');
     /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
-    $storage = $this->container->get('keyvalue')
+    $storage = $this->container->get('keyvalue.expirable')
       ->get('project_browser:project_browser_test_mock');
+    $get_query_cache_keys = function () use ($storage): array {
+      return preg_grep('/^query:[0-9a-f]+$/', array_keys($storage->getAll()));
+    };
+
+    $handler->getProjects('project_browser_test_mock');
     // Query results should have been stored.
-    $query_cache_key = 'query:' . md5('[]');
-    $this->assertTrue($storage->has($query_cache_key));
+    $this->assertNotEmpty($get_query_cache_keys());
 
     $handler->clearStorage();
     ProjectBrowserTestMock::$resultsError = 'Nope!';
 
     $handler->getProjects('project_browser_test_mock');
-    // No query results should have been stored.
-    $this->assertFalse($storage->has($query_cache_key));
+    // No new query results should have been stored.
+    $this->assertEmpty($get_query_cache_keys());
   }
 
   /**
-- 
GitLab


From 3ce0195807d4205e8d980fe6257f4cd71633c11e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 10:43:10 -0500
Subject: [PATCH 03/12] Refactor to shut PHPStan the HELL up

---
 tests/src/Functional/EnabledSourceHandlerTest.php | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index 5dac6b3e8..3207ab8cb 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -66,23 +66,23 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     /** @var \Drupal\project_browser\EnabledSourceHandler $handler */
     $handler = $this->container->get(EnabledSourceHandler::class);
 
-    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
-    $storage = $this->container->get('keyvalue.expirable')
-      ->get('project_browser:project_browser_test_mock');
-    $get_query_cache_keys = function () use ($storage): array {
-      return preg_grep('/^query:[0-9a-f]+$/', array_keys($storage->getAll()));
+    $has_cached_queries = function (): bool {
+      /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
+      $storage = $this->container->get('keyvalue.expirable')
+        ->get('project_browser:project_browser_test_mock');
+      return (bool) preg_grep('/^query:[0-9a-f]+$/', array_keys($storage->getAll()));
     };
 
     $handler->getProjects('project_browser_test_mock');
     // Query results should have been stored.
-    $this->assertNotEmpty($get_query_cache_keys());
+    $this->assertTrue($has_cached_queries());
 
     $handler->clearStorage();
     ProjectBrowserTestMock::$resultsError = 'Nope!';
 
     $handler->getProjects('project_browser_test_mock');
     // No new query results should have been stored.
-    $this->assertEmpty($get_query_cache_keys());
+    $this->assertFalse($has_cached_queries());
   }
 
   /**
-- 
GitLab


From 47563e0aa9cbf84cf44e9767f288bc351d830d65 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 10:46:50 -0500
Subject: [PATCH 04/12] Fix ClearStorageTest

---
 tests/src/Functional/ClearStorageTest.php | 29 +++++++++++++----------
 1 file changed, 16 insertions(+), 13 deletions(-)

diff --git a/tests/src/Functional/ClearStorageTest.php b/tests/src/Functional/ClearStorageTest.php
index d0be6f3f6..98a452504 100644
--- a/tests/src/Functional/ClearStorageTest.php
+++ b/tests/src/Functional/ClearStorageTest.php
@@ -28,13 +28,6 @@ class ClearStorageTest extends BrowserTestBase {
    */
   protected $defaultTheme = 'stark';
 
-  /**
-   * The cache key under which project data is stored in key-value.
-   *
-   * @var string
-   */
-  private readonly string $cacheKey;
-
   /**
    * The key-value storage.
    *
@@ -52,12 +45,12 @@ class ClearStorageTest extends BrowserTestBase {
       ->set('enabled_sources', ['project_browser_test_mock'])
       ->save();
 
-    $this->cacheKey = 'query:' . md5('[]');
-    $this->keyValue = \Drupal::service('keyvalue')->get('project_browser:project_browser_test_mock');
+    $this->keyValue = \Drupal::service('keyvalue.expirable')
+      ->get('project_browser:project_browser_test_mock');
 
     // Warm the project cache and confirm it is populated.
     \Drupal::service(EnabledSourceHandler::class)->getProjects('project_browser_test_mock');
-    $this->assertNotEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertTrue($this->hasCachedQueries());
   }
 
   /**
@@ -67,7 +60,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheDirectly(): void {
     \Drupal::service(EnabledSourceHandler::class)->clearStorage();
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertFalse($this->hasCachedQueries());
   }
 
   /**
@@ -81,7 +74,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheWithDrush(string $command): void {
     $this->drush($command);
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertFalse($this->hasCachedQueries());
   }
 
   /**
@@ -95,7 +88,17 @@ class ClearStorageTest extends BrowserTestBase {
     $assert_session = $this->assertSession();
     $assert_session->buttonExists('Clear storage')->press();
     $assert_session->statusMessageContains('Storage cleared.');
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertFalse($this->hasCachedQueries());
+  }
+
+  /**
+   * Checks if there are any cached query results.
+   *
+   * @return bool
+   *   Whether there are any cached query results.
+   */
+  private function hasCachedQueries(): bool {
+    return (bool) preg_grep('/^query:[0-9a-f]+$/', array_keys($this->keyValue->getAll()));
   }
 
 }
-- 
GitLab


From 1aee84181498040a64855fea5f8dff5a8804df96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 12:15:56 -0500
Subject: [PATCH 05/12] Refactor ProjectBrowserInstallerUiTest to not be
 order-senstiive

---
 .../ProjectBrowserInstallerUiTest.php         | 89 ++++++++++++++-----
 1 file changed, 68 insertions(+), 21 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 2e1560123..16212301d 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -195,10 +195,14 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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");
+    $cream_cheese = $this->getProjectCard('Cream cheese on a bagel');
+    $download_button = $cream_cheese->waitFor(
+      10,
+      function (NodeElement $card): ?NodeElement {
+        return $card->findButton('Install Cream cheese on a bagel');
+      },
+    );
     $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');
@@ -207,9 +211,13 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     $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");
+    $action_button = $cream_cheese->waitFor(
+      10,
+      function (NodeElement $card): ?NodeElement {
+        return $card->findButton('View Commands for Cream cheese on a bagel');
+      },
+    );
     $this->assertNotEmpty($action_button);
-    $this->assertSame('View Commands for Cream cheese on a bagel', $action_button->getText());
   }
 
   /**
@@ -219,7 +227,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', [
@@ -230,9 +237,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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();
+    $cream_cheese = $this->getProjectCard('Cream cheese on a bagel');
+    $cream_cheese->find('css', "button.pb__action_button")?->click();
 
     $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.'));
 
@@ -240,12 +246,56 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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());
+    $cream_cheese->find('css', "button.pb__action_button")?->click();
+    $this->waitForInstallToFinish($cream_cheese);
+  }
+
+  /**
+   * Waits for a project to be visible and returns its list item (card).
+   *
+   * @param string $name
+   *   The human-readable name of the project.
+   */
+  private function getProjectCard(string $name): NodeElement {
+    $element = $this->assertSession()
+      ->elementExists('css', ".pb-project__title:contains('$name')");
 
+    // Move upwards in the document until we find the card.
+    while (!$element->hasClass('pb-project')) {
+      $element = $element->getParent();
+    }
+    $this->assertNotEmpty($element);
+    return $element;
+  }
+
+  /**
+   * Waits for a project to finish installing.
+   *
+   * @param \Behat\Mink\Element\NodeElement $card
+   *   The project's list item (card) element.
+   * @param int $timeout
+   *   (optional) How to long to wait for the install finish, in seconds.
+   *   Defaults to 30.
+   */
+  private function waitForInstallToFinish(NodeElement $card, int $timeout = 30): void {
+    // First, wait for the status indicator element to appear at all.
+    $indicator = $card->waitFor(
+      $timeout,
+      function (NodeElement $card): ?NodeElement {
+        $indicator = $card->find('css', '.project_status-indicator');
+        return $indicator?->isVisible() ? $indicator : NULL;
+      },
+    );
+
+    $name = $card->find('css', '.pb-project__title')?->getText();
+    $this->assertNotEmpty($name);
+    $was_installed = $indicator?->waitFor(
+      $timeout,
+      function (NodeElement $indicator) use ($name): bool {
+        return $indicator->getText() === "$name is Installed";
+      },
+    );
+    $this->assertTrue($was_installed);
   }
 
   /**
@@ -257,7 +307,6 @@ 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', [
@@ -267,19 +316,17 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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 = $this->getProjectCard('Cream cheese on a bagel');
+    $cream_cheese_button = $cream_cheese->find('css', "button.pb__action_button");
     $cream_cheese_button?->click();
     $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 = $cream_cheese->find('css', "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->waitForInstallToFinish($cream_cheese);
   }
 
   /**
-- 
GitLab


From 42cf359cd938ba544dae83501a1228e1f0ae88f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 12:28:45 -0500
Subject: [PATCH 06/12] Fix one more failure

---
 .../ProjectBrowserInstallerUiTest.php           | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 16212301d..6edd48074 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -514,7 +514,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * 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.
@@ -525,16 +524,18 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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();
+    $cream_cheese = $this->getProjectCard('Cream cheese on a bagel');
+    $cream_cheese->find('css', "button.pb__action_button")?->click();
     // 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);
+    $download_button = $cream_cheese->waitFor(
+      10,
+      function (NodeElement $card): ?NodeElement {
+        return $card->findButton('Install Cream cheese on a bagel');
+      },
+    );
     // Assertion that the install state does not change.
-    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
-
+    $this->assertNotEmpty($download_button?->isVisible());
   }
 
 }
-- 
GitLab


From 7b737d94929e70618d810eaae56216f8c1dcd094 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 12:44:21 -0500
Subject: [PATCH 07/12] Revert changes to JS tests because clearly something
 broke normal sorting

---
 .../ProjectBrowserInstallerUiTest.php         | 106 +++++-------------
 1 file changed, 29 insertions(+), 77 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 6edd48074..2e1560123 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -195,14 +195,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', 'Pinky and the Brain');
 
-    $cream_cheese = $this->getProjectCard('Cream cheese on a bagel');
-    $download_button = $cream_cheese->waitFor(
-      10,
-      function (NodeElement $card): ?NodeElement {
-        return $card->findButton('Install 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());
     $this->drupalGet('/admin/config/development/project_browser');
     $page->find('css', '#edit-allow-ui-install')?->click();
     $assert_session->checkboxNotChecked('edit-allow-ui-install');
@@ -211,13 +207,9 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
-    $action_button = $cream_cheese->waitFor(
-      10,
-      function (NodeElement $card): ?NodeElement {
-        return $card->findButton('View Commands for 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());
   }
 
   /**
@@ -227,6 +219,7 @@ 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', [
@@ -237,8 +230,9 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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 = $this->getProjectCard('Cream cheese on a bagel');
-    $cream_cheese->find('css', "button.pb__action_button")?->click();
+    $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->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.'));
 
@@ -246,56 +240,12 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->clickWithWait('#ui-id-1 > p > a');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install after breaking lock.
-    $cream_cheese->find('css', "button.pb__action_button")?->click();
-    $this->waitForInstallToFinish($cream_cheese);
-  }
-
-  /**
-   * Waits for a project to be visible and returns its list item (card).
-   *
-   * @param string $name
-   *   The human-readable name of the project.
-   */
-  private function getProjectCard(string $name): NodeElement {
-    $element = $this->assertSession()
-      ->elementExists('css', ".pb-project__title:contains('$name')");
-
-    // Move upwards in the document until we find the card.
-    while (!$element->hasClass('pb-project')) {
-      $element = $element->getParent();
-    }
-    $this->assertNotEmpty($element);
-    return $element;
-  }
+    $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());
 
-  /**
-   * Waits for a project to finish installing.
-   *
-   * @param \Behat\Mink\Element\NodeElement $card
-   *   The project's list item (card) element.
-   * @param int $timeout
-   *   (optional) How to long to wait for the install finish, in seconds.
-   *   Defaults to 30.
-   */
-  private function waitForInstallToFinish(NodeElement $card, int $timeout = 30): void {
-    // First, wait for the status indicator element to appear at all.
-    $indicator = $card->waitFor(
-      $timeout,
-      function (NodeElement $card): ?NodeElement {
-        $indicator = $card->find('css', '.project_status-indicator');
-        return $indicator?->isVisible() ? $indicator : NULL;
-      },
-    );
-
-    $name = $card->find('css', '.pb-project__title')?->getText();
-    $this->assertNotEmpty($name);
-    $was_installed = $indicator?->waitFor(
-      $timeout,
-      function (NodeElement $indicator) use ($name): bool {
-        return $indicator->getText() === "$name is Installed";
-      },
-    );
-    $this->assertTrue($was_installed);
   }
 
   /**
@@ -307,6 +257,7 @@ 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', [
@@ -316,17 +267,19 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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 = $this->getProjectCard('Cream cheese on a bagel');
-    $cream_cheese_button = $cream_cheese->find('css', "button.pb__action_button");
+    $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->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 = $cream_cheese->find('css', "button.pb__action_button");
+    $cream_cheese_button = $page->find('css', "$cream_cheese_module_selector button.pb__action_button");
     $cream_cheese_button?->click();
-    $this->waitForInstallToFinish($cream_cheese);
+    $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());
   }
 
   /**
@@ -514,6 +467,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * 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.
@@ -524,18 +478,16 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $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 = $this->getProjectCard('Cream cheese on a bagel');
-    $cream_cheese->find('css', "button.pb__action_button")?->click();
+    $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();
     // Close the dialog to assert the state of install button.
     $page->find('css', '.ui-dialog-titlebar-close')?->click();
-    $download_button = $cream_cheese->waitFor(
-      10,
-      function (NodeElement $card): ?NodeElement {
-        return $card->findButton('Install Cream cheese on a bagel');
-      },
-    );
+    $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->assertNotEmpty($download_button?->isVisible());
+    $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
+
   }
 
 }
-- 
GitLab


From 8629bdf658c47228a0c8255fae904a2735421c0c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 21 Feb 2025 11:18:38 -0500
Subject: [PATCH 08/12] Fix test, hopefully

---
 .../ProjectBrowserUiTest.php                  | 97 ++++++++++++-------
 .../ProjectBrowserUiTestTrait.php             | 38 --------
 2 files changed, 61 insertions(+), 74 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index fa833827f..6f7976f79 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
+use Behat\Mink\Element\DocumentElement;
 use Behat\Mink\Element\NodeElement;
 use Drupal\Core\Extension\MissingDependencyException;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
@@ -149,14 +150,14 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // Svelte app has occasional initialization problems on GitLabCI that are
     // reliably fixed by a page reload, so we allow that here to prevent random
     // failures that are not representative of real world use.
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Cream cheese on a bagel',
       'Dancing Queen',
       'Kangaroo',
       '9 Starts With a Higher Number',
       'Helvetica',
       'Astronaut Simulator',
-    ], TRUE);
+    ]);
 
     // Clear the checkbox to verify the results revert to their initial state.
     $this->clickWithWait('#104', '10 Results');
@@ -178,7 +179,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // Make sure the 'Media' module category filter is applied.
     $assert_category_filters_applied(['Media', 'E-commerce']);
     // Assert that only media and administration module categories are shown.
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Eggman',
       'Tooth Fairy',
@@ -245,7 +246,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', '10 Results');
 
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Cream cheese on a bagel',
       'Pinky and the Brain',
       'Dancing Queen',
@@ -261,7 +262,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     $page->pressButton('Clear filters');
     $this->assertTrue($assert_session->waitForText('25 Results'));
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Eggman',
       'Tooth Fairy',
@@ -279,7 +280,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $assert_session->elementExists('css', '.pager__item--active > .is-active[aria-label="Page 1"]');
 
     $this->clickWithWait('[aria-label="Next page"]');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       '9 Starts With a Higher Number',
       'Quiznos',
       'Octopus',
@@ -296,7 +297,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->assertPagerItems(['First', 'Previous', '1', '2', '3', 'Next', 'Last']);
 
     $this->clickWithWait('[aria-label="Next page"]');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Astronaut Simulator',
     ]);
     $this->assertPagerItems(['First', 'Previous', '1', '2', '3']);
@@ -351,7 +352,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->svelteInitHelper('text', 'Astronaut Simulator');
     $this->pressWithWait('Clear filters');
     $this->pressWithWait('Recommended filters');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Cream cheese on a bagel',
       'Pinky and the Brain',
       'Dancing Queen',
@@ -369,7 +370,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     // Clear the security covered filter.
     $this->clickWithWait(self::SECURITY_OPTION_SELECTOR . self::OPTION_LAST_CHILD);
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Vitamin&C;$?',
       'Cream cheese on a bagel',
@@ -392,7 +393,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // Click the Active filter.
     $page->selectFieldOption('development_status', 'Show projects under active development');
 
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Cream cheese on a bagel',
       'Ice Ice',
@@ -409,7 +410,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     // Click the "Show all" filter for security.
     $this->clickWithWait(self::SECURITY_OPTION_SELECTOR . self::OPTION_LAST_CHILD, '', TRUE);
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Cream cheese on a bagel',
       'Ice Ice',
@@ -431,7 +432,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->clickWithWait(self::MAINTENANCE_OPTION_SELECTOR . self::OPTION_FIRST_CHILD);
     $this->assertEquals('Show actively maintained projects', $this->getElementText(self::MAINTENANCE_OPTION_SELECTOR . self::OPTION_CHECKED));
 
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Vitamin&C;$?',
       'Cream cheese on a bagel',
@@ -466,7 +467,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->sortBy('a_z');
 
     // Assert that the projects are listed in ascending order of their titles.
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       '1 Starts With a Number',
       '9 Starts With a Higher Number',
       'Astronaut Simulator',
@@ -484,7 +485,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // Select 'Z-A' sorting order.
     $this->sortBy('z_a');
 
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
       'Unwritten&:/',
       'Tooth Fairy',
@@ -503,7 +504,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->sortBy('usage_total');
 
     // Assert that the projects are listed in descending order of their usage.
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Eggman',
       'Tooth Fairy',
@@ -523,7 +524,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     // Assert that the projects are listed in descending order of their date of
     // creation.
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       '9 Starts With a Higher Number',
       'Helvetica',
       'Become a Banana',
@@ -554,7 +555,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('&', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
       'Unwritten&:/',
     ]);
@@ -563,7 +564,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('n&', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
       'Unwritten&:/',
     ]);
@@ -571,28 +572,28 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('$', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
     ]);
 
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('?', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
     ]);
 
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('&:', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Unwritten&:/',
     ]);
 
     $this->inputSearchField('', TRUE);
     $this->inputSearchField('$?', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Vitamin&C;$?',
     ]);
   }
@@ -667,7 +668,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->clickWithWait('#67', '', TRUE);
 
     $this->assertTrue($assert_session->waitForText('15 Results'));
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Octopus',
       'No Scrubs',
       'Mad About You',
@@ -683,7 +684,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     ]);
 
     $this->clickWithWait('[aria-label="Next page"]');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Astronaut Simulator',
       '9 Starts With a Higher Number',
       '1 Starts With a Number',
@@ -691,7 +692,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->getSession()->reload();
     // Should still be on second results page.
     $this->svelteInitHelper('css', '#project-browser .pb-project');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Astronaut Simulator',
       '9 Starts With a Higher Number',
       '1 Starts With a Number',
@@ -702,7 +703,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->assertEquals('Media', $this->getElementText('p.filter-applied:nth-child(2) .filter-applied__label'));
 
     $this->clickWithWait('[aria-label="First page"]');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Octopus',
       'No Scrubs',
       'Mad About You',
@@ -715,7 +716,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
       'Dancing Queen',
       'Cream cheese on a bagel',
       'Become a Banana',
-    ], TRUE);
+    ]);
 
     $this->assertEquals('E-commerce', $this->getElementText('p.filter-applied:first-child .filter-applied__label'));
     $this->assertEquals('Media', $this->getElementText('p.filter-applied:nth-child(2) .filter-applied__label'));
@@ -748,7 +749,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $page = $this->getSession()->getPage();
     $assert_session = $this->assertSession();
     // Enable module for extra source plugin.
-    $this->container->get('module_installer')->install(['project_browser_devel'], TRUE);
+    $this->container->get('module_installer')->install(['project_browser_devel']);
     // Test categories with multiple plugin enabled.
     $this->drupalGet('admin/modules/browse');
     $this->svelteInitHelper('css', '.pb-filter__checkbox');
@@ -838,7 +839,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->inputSearchField('Number', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
     $this->assertTrue($assert_session->waitForText('2 Results'));
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       '9 Starts With a Higher Number',
       '1 Starts With a Number',
     ]);
@@ -850,7 +851,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->svelteInitHelper('css', '#project-browser .pb-project');
     // Assert that the filters persist.
     $this->assertTrue($assert_session->waitForText('2 Results'));
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       '9 Starts With a Higher Number',
       '1 Starts With a Number',
     ]);
@@ -987,7 +988,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->pressWithWait('Clear filters');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Jazz',
       'Eggman',
       'Tooth Fairy',
@@ -1004,7 +1005,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     $this->assertPagerItems(['1', '2', '3', 'Next', 'Last']);
     $this->clickWithWait('[aria-label="Last page"]');
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Astronaut Simulator',
     ]);
 
@@ -1055,7 +1056,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     // @todo Remove try/catch in https://www.drupal.org/i/3349193.
     try {
-      $this->container->get('module_installer')->install(['package_manager'], TRUE);
+      $this->container->get('module_installer')->install(['package_manager']);
     }
     catch (MissingDependencyException $e) {
       $this->markTestSkipped($e->getMessage());
@@ -1174,7 +1175,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $assert_session = $this->assertSession();
 
     // Enable module for extra source plugin.
-    $this->container->get('module_installer')->install(['project_browser_devel'], TRUE);
+    $this->container->get('module_installer')->install(['project_browser_devel']);
     $this->rebuildContainer();
 
     $current_sources = $this->container->get(EnabledSourceHandler::class)->getCurrentSources();
@@ -1308,7 +1309,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $search_field = $assert_session->waitForElementVisible('css', '#pb-text');
     $this->inputSearchField('Tooth Fairy', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Tooth Fairy',
     ]);
     // Check search box still has search text.
@@ -1321,7 +1322,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // Run search again.
     $this->inputSearchField('Tooth Fairy', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")?->click();
-    $this->assertProjectsVisible([
+    $this->waitForProjects([
       'Tooth Fairy',
     ]);
 
@@ -1330,4 +1331,28 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->assertEquals($search_field->getValue(), '');
   }
 
+  /**
+   * Asserts that a given list of project titles are visible on the page.
+   *
+   * This doesn't assert that the projects appear in any particular order, only
+   * that they appear somewhere in the project browser.
+   *
+   * @param array $project_titles
+   *   An array of expected titles.
+   */
+  private function waitForProjects(array $project_titles): void {
+    $missing = [];
+    $success = $this->getSession()
+      ->getPage()
+      ->waitFor(10, function (DocumentElement $page) use ($project_titles, &$missing): bool {
+        $projects_on_page = array_map(
+          fn (NodeElement $element) => $element->getText(),
+          $page->findAll('css', '#project-browser .pb-project h3 button'),
+        );
+        $missing = array_diff($project_titles, $projects_on_page);
+        return empty($missing);
+      });
+    $this->assertTrue($success, sprintf('The following projects should have appeared, but did not: %s', implode(', ', $missing)));
+  }
+
 }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
index b7f40a120..ce6632a1c 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTestTrait.php
@@ -98,44 +98,6 @@ trait ProjectBrowserUiTestTrait {
     $this->assertInstanceOf(NodeElement::class, $indicator);
   }
 
-  /**
-   * Asserts that a given list of project titles are visible on the page.
-   *
-   * @param array $project_titles
-   *   An array of expected titles.
-   * @param bool $reload
-   *   When TRUE, reload the page if the assertion fails and try again.
-   *   This should typically be kept to the default value of FALSE. It only
-   *   needs to be set to TRUE for calls that intermittently fail on GitLabCI.
-   */
-  protected function assertProjectsVisible(array $project_titles, bool $reload = FALSE): void {
-    $count = count($project_titles);
-
-    // Create a JavaScript string that checks the titles of the visible
-    // projects. This is done with JavaScript to avoid issues with PHP
-    // referencing an element that was rerendered and thus unavailable.
-    $script = "document.querySelectorAll('#project-browser .pb-project h3 button').length === $count";
-    foreach ($project_titles as $key => $value) {
-      $script .= " && document.querySelectorAll('#project-browser .pb-project h3 button')[$key].textContent === '$value'";
-    }
-
-    // It can take a while for all items to render. Wait for the condition to be
-    // true before asserting it.
-    $this->getSession()->wait(10000, $script);
-
-    if ($reload) {
-      try {
-        $this->assertTrue($this->getSession()->evaluateScript($script), 'Ran:' . $script . 'Svelte did not initialize. Markup: ' . $this->getSession()->evaluateScript('document.querySelector("#project-browser").innerHTML'));
-      }
-      catch (\Exception) {
-        $this->getSession()->reload();
-        $this->getSession()->wait(10000, $script);
-      }
-    }
-
-    $this->assertTrue($this->getSession()->evaluateScript($script), 'Ran:' . $script . 'Svelte did not initialize. Markup: ' . $this->getSession()->evaluateScript('document.querySelector("#project-browser").innerHTML'));
-  }
-
   /**
    * Searches for a term in the search field.
    *
-- 
GitLab


From becbd94d408f98b99f68841953dae289e1c26060 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 21 Feb 2025 11:40:17 -0500
Subject: [PATCH 09/12] Make testCategoriesVisibility() non-flaky

---
 .../ProjectBrowserUiTest.php                  | 41 +++++++------------
 1 file changed, 14 insertions(+), 27 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index 6f7976f79..d6df2e278 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -948,36 +948,23 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
   /**
    * Tests the visibility of categories in list and grid view.
+   *
+   * @testWith ["List"]
+   *   ["Grid"]
    */
-  public function testCategoriesVisibility(): void {
-    $page = $this->getSession()->getPage();
-    $assert_session = $this->assertSession();
-    $view_options = [
-      [
-        'selector' => '.pb-display__button[value="Grid"]',
-        'value' => 'Grid',
-      ], [
-        'selector' => '.pb-display__button[value="List"]',
-        'value' => 'List',
-      ],
-    ];
+  public function testCategoriesVisibility(string $display_type): void {
     $this->getSession()->resizeWindow(1300, 1300);
 
-    // Check visibility of categories in each view.
-    foreach ($view_options as $selector) {
-      $this->drupalGet('admin/modules/browse/project_browser_test_mock');
-      $this->svelteInitHelper('css', $selector['selector']);
-      $page->pressButton($selector['value']);
-      $this->svelteInitHelper('text', 'Helvetica');
-      $assert_session->elementsCount('css', '#project-browser .pb-layout__main ul li:nth-child(7) .pb-project-categories ul li', 1);
-      $grid_text = $this->getElementText('#project-browser .pb-layout__main ul li:nth-child(7) .pb-project-categories ul li:nth-child(1)');
-      $this->assertEquals('E-commerce', $grid_text);
-      $assert_session->elementsCount('css', '#project-browser .pb-layout__main  ul li:nth-child(10) .pb-project-categories ul li', 2);
-      $grid_text = $this->getElementText('#project-browser .pb-layout__main ul li:nth-child(7) .pb-project-categories ul li:nth-child(1)');
-      $this->assertEquals('E-commerce', $grid_text);
-      $grid_text = $this->getElementText('#project-browser .pb-layout__main ul li:nth-child(10) .pb-project-categories ul li:nth-child(2)');
-      $this->assertEquals('E-commerce', $grid_text);
-    }
+    $this->drupalGet('admin/modules/browse/project_browser_test_mock');
+    $this->assertSession()->waitForButton($display_type)?->press();
+
+    $helvetica = $this->waitForProject('Helvetica');
+    $this->assertSame('E-commerce', $helvetica->find('css', '.pb-project-categories ul li')?->getText());
+
+    $astronaut_simulator_categories = $this->waitForProject('Astronaut Simulator')
+      ->findAll('css', '.pb-project-categories ul li');
+    $this->assertCount(2, $astronaut_simulator_categories);
+    $this->assertSame('E-commerce', end($astronaut_simulator_categories)->getText());
   }
 
   /**
-- 
GitLab


From 7c2c2ca04fbb0807593757c90dfd6eac93bee1a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 25 Feb 2025 13:41:46 -0500
Subject: [PATCH 10/12] Revert all tests

---
 tests/src/Functional/ClearStorageTest.php     | 29 +++++++++----------
 .../Functional/EnabledSourceHandlerTest.php   | 17 +++++------
 2 files changed, 20 insertions(+), 26 deletions(-)

diff --git a/tests/src/Functional/ClearStorageTest.php b/tests/src/Functional/ClearStorageTest.php
index 98a452504..d0be6f3f6 100644
--- a/tests/src/Functional/ClearStorageTest.php
+++ b/tests/src/Functional/ClearStorageTest.php
@@ -28,6 +28,13 @@ class ClearStorageTest extends BrowserTestBase {
    */
   protected $defaultTheme = 'stark';
 
+  /**
+   * The cache key under which project data is stored in key-value.
+   *
+   * @var string
+   */
+  private readonly string $cacheKey;
+
   /**
    * The key-value storage.
    *
@@ -45,12 +52,12 @@ class ClearStorageTest extends BrowserTestBase {
       ->set('enabled_sources', ['project_browser_test_mock'])
       ->save();
 
-    $this->keyValue = \Drupal::service('keyvalue.expirable')
-      ->get('project_browser:project_browser_test_mock');
+    $this->cacheKey = 'query:' . md5('[]');
+    $this->keyValue = \Drupal::service('keyvalue')->get('project_browser:project_browser_test_mock');
 
     // Warm the project cache and confirm it is populated.
     \Drupal::service(EnabledSourceHandler::class)->getProjects('project_browser_test_mock');
-    $this->assertTrue($this->hasCachedQueries());
+    $this->assertNotEmpty($this->keyValue->get($this->cacheKey));
   }
 
   /**
@@ -60,7 +67,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheDirectly(): void {
     \Drupal::service(EnabledSourceHandler::class)->clearStorage();
-    $this->assertFalse($this->hasCachedQueries());
+    $this->assertEmpty($this->keyValue->get($this->cacheKey));
   }
 
   /**
@@ -74,7 +81,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheWithDrush(string $command): void {
     $this->drush($command);
-    $this->assertFalse($this->hasCachedQueries());
+    $this->assertEmpty($this->keyValue->get($this->cacheKey));
   }
 
   /**
@@ -88,17 +95,7 @@ class ClearStorageTest extends BrowserTestBase {
     $assert_session = $this->assertSession();
     $assert_session->buttonExists('Clear storage')->press();
     $assert_session->statusMessageContains('Storage cleared.');
-    $this->assertFalse($this->hasCachedQueries());
-  }
-
-  /**
-   * Checks if there are any cached query results.
-   *
-   * @return bool
-   *   Whether there are any cached query results.
-   */
-  private function hasCachedQueries(): bool {
-    return (bool) preg_grep('/^query:[0-9a-f]+$/', array_keys($this->keyValue->getAll()));
+    $this->assertEmpty($this->keyValue->get($this->cacheKey));
   }
 
 }
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index 3207ab8cb..f0b9ce8b4 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -66,23 +66,20 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     /** @var \Drupal\project_browser\EnabledSourceHandler $handler */
     $handler = $this->container->get(EnabledSourceHandler::class);
 
-    $has_cached_queries = function (): bool {
-      /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
-      $storage = $this->container->get('keyvalue.expirable')
-        ->get('project_browser:project_browser_test_mock');
-      return (bool) preg_grep('/^query:[0-9a-f]+$/', array_keys($storage->getAll()));
-    };
-
     $handler->getProjects('project_browser_test_mock');
+    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
+    $storage = $this->container->get('keyvalue')
+      ->get('project_browser:project_browser_test_mock');
     // Query results should have been stored.
-    $this->assertTrue($has_cached_queries());
+    $query_cache_key = 'query:' . md5('[]');
+    $this->assertTrue($storage->has($query_cache_key));
 
     $handler->clearStorage();
     ProjectBrowserTestMock::$resultsError = 'Nope!';
 
     $handler->getProjects('project_browser_test_mock');
-    // No new query results should have been stored.
-    $this->assertFalse($has_cached_queries());
+    // No query results should have been stored.
+    $this->assertFalse($storage->has($query_cache_key));
   }
 
   /**
-- 
GitLab


From b8606d87ed220092c2d2384eb5d07f027c080c2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 25 Feb 2025 13:43:30 -0500
Subject: [PATCH 11/12] Revert expirable

---
 project_browser.services.yml |  2 +-
 src/EnabledSourceHandler.php | 16 ++++++++--------
 2 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/project_browser.services.yml b/project_browser.services.yml
index 3b18bdd9c..4e2e42215 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -9,7 +9,7 @@ services:
     parent: default_plugin_manager
   Drupal\project_browser\EnabledSourceHandler:
     arguments:
-      $keyValueFactory: '@keyvalue.expirable'
+      $keyValueFactory: '@keyvalue'
     calls:
       - [setLogger, ['@logger.channel.project_browser']]
     tags:
diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 5c25daa9d..c223bde84 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -7,8 +7,8 @@ use Drupal\Component\Serialization\Json;
 use Drupal\Core\Config\ConfigCrudEvent;
 use Drupal\Core\Config\ConfigEvents;
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
-use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
@@ -26,7 +26,7 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
   public function __construct(
     private readonly ConfigFactoryInterface $configFactory,
     private readonly ProjectBrowserSourceManager $pluginManager,
-    private readonly KeyValueExpirableFactoryInterface $keyValueFactory,
+    private readonly KeyValueFactoryInterface $keyValueFactory,
   ) {}
 
   /**
@@ -35,10 +35,10 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
    * @param string $source_id
    *   The ID of a source plugin.
    *
-   * @return \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
+   * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
    *   A key-value store for the specified source plugin.
    */
-  private function keyValue(string $source_id): KeyValueStoreExpirableInterface {
+  private function keyValue(string $source_id): KeyValueStoreInterface {
     return $this->keyValueFactory->get("project_browser:$source_id");
   }
 
@@ -132,15 +132,15 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
       $storage->setIfNotExists($project->id, $project);
     }
     // If there were no query errors, store the results as a set of arguments
-    // to ProjectsResultsPage. These cached results automatically expire daily.
+    // to ProjectsResultsPage.
     if (empty($results->error)) {
-      $storage->setWithExpire($cache_key, [
+      $storage->set($cache_key, [
         $results->totalResults,
         array_column($results->list, 'id'),
         $results->pluginLabel,
         $source_id,
         $results->error,
-      ], 86400);
+      ]);
     }
     return $results;
   }
-- 
GitLab


From aa606404908449bb8e09c6b6a63166448bfc5ea3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 25 Feb 2025 14:24:03 -0500
Subject: [PATCH 12/12] Fix functional tests

---
 tests/src/Functional/ClearStorageTest.php     | 16 ++++------------
 .../Functional/EnabledSourceHandlerTest.php   | 19 ++++++++++++-------
 2 files changed, 16 insertions(+), 19 deletions(-)

diff --git a/tests/src/Functional/ClearStorageTest.php b/tests/src/Functional/ClearStorageTest.php
index d0be6f3f6..a1209bc38 100644
--- a/tests/src/Functional/ClearStorageTest.php
+++ b/tests/src/Functional/ClearStorageTest.php
@@ -28,13 +28,6 @@ class ClearStorageTest extends BrowserTestBase {
    */
   protected $defaultTheme = 'stark';
 
-  /**
-   * The cache key under which project data is stored in key-value.
-   *
-   * @var string
-   */
-  private readonly string $cacheKey;
-
   /**
    * The key-value storage.
    *
@@ -52,12 +45,11 @@ class ClearStorageTest extends BrowserTestBase {
       ->set('enabled_sources', ['project_browser_test_mock'])
       ->save();
 
-    $this->cacheKey = 'query:' . md5('[]');
     $this->keyValue = \Drupal::service('keyvalue')->get('project_browser:project_browser_test_mock');
 
     // Warm the project cache and confirm it is populated.
     \Drupal::service(EnabledSourceHandler::class)->getProjects('project_browser_test_mock');
-    $this->assertNotEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertNotEmpty($this->keyValue->getAll());
   }
 
   /**
@@ -67,7 +59,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheDirectly(): void {
     \Drupal::service(EnabledSourceHandler::class)->clearStorage();
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertEmpty($this->keyValue->getAll());
   }
 
   /**
@@ -81,7 +73,7 @@ class ClearStorageTest extends BrowserTestBase {
    */
   public function testClearCacheWithDrush(string $command): void {
     $this->drush($command);
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertEmpty($this->keyValue->getAll());
   }
 
   /**
@@ -95,7 +87,7 @@ class ClearStorageTest extends BrowserTestBase {
     $assert_session = $this->assertSession();
     $assert_session->buttonExists('Clear storage')->press();
     $assert_session->statusMessageContains('Storage cleared.');
-    $this->assertEmpty($this->keyValue->get($this->cacheKey));
+    $this->assertEmpty($this->keyValue->getAll());
   }
 
 }
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index f0b9ce8b4..aea25f80f 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -65,21 +65,26 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
   public function testErrorsAreNotStored(): void {
     /** @var \Drupal\project_browser\EnabledSourceHandler $handler */
     $handler = $this->container->get(EnabledSourceHandler::class);
-
     $handler->getProjects('project_browser_test_mock');
-    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $storage */
-    $storage = $this->container->get('keyvalue')
-      ->get('project_browser:project_browser_test_mock');
+
+    $has_cached_queries = function (): bool {
+      $items = $this->container->get('keyvalue')
+        ->get('project_browser:project_browser_test_mock')
+        ->getAll();
+      return (bool) array_filter(
+        array_keys($items),
+        fn (string $key): bool => str_starts_with($key, 'query:'),
+      );
+    };
     // Query results should have been stored.
-    $query_cache_key = 'query:' . md5('[]');
-    $this->assertTrue($storage->has($query_cache_key));
+    $this->assertTrue($has_cached_queries());
 
     $handler->clearStorage();
     ProjectBrowserTestMock::$resultsError = 'Nope!';
 
     $handler->getProjects('project_browser_test_mock');
     // No query results should have been stored.
-    $this->assertFalse($storage->has($query_cache_key));
+    $this->assertFalse($has_cached_queries());
   }
 
   /**
-- 
GitLab