From 637c05ebc9c8e2e287b21c97fe6dd680a77f5bfc Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Tue, 25 Feb 2025 18:25:33 +0100
Subject: [PATCH 1/5] Add missing test coverage for the `status` syncing that
 DOES work.

---
 .../Functional/XbConfigEntityHttpApiTest.php  | 61 +++++++++++++++++++
 1 file changed, 61 insertions(+)

diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php
index 003613ecb3..91160b35fb 100644
--- a/tests/src/Functional/XbConfigEntityHttpApiTest.php
+++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php
@@ -31,6 +31,7 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     'block',
     'experience_builder',
     'xb_test_sdc',
+    'node',
   ];
 
   /**
@@ -602,6 +603,10 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
       'js_footer' => '',
     ];
     $this->assertSame($expected_component, $body);
+    // Confirm that the code component IS NOT exposed.
+    // @see docs/config-management.md#3.2.1
+    $this->assertExposedCodeComponents([], 'MISS', $request_options);
+    $this->assertExposedCodeComponents([], 'HIT', $request_options);
     // Confirm no auto-save entity has been created.
     $this->assertExpectedResponse('GET', $auto_save_url, $request_options, 204, ['user.permissions'], ['experience_builder__autosave', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertExpectedResponse('GET', $auto_save_url, $request_options, 204, ['user.permissions'], ['experience_builder__autosave', 'http_response'], 'UNCACHEABLE (request policy)', 'HIT');
@@ -678,6 +683,10 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
       'http_response',
     ], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertSame(['test' => $expected_component], $body);
+    // Confirm that the code component IS STILL NOT exposed, because `status` is
+    // still `FALSE`.
+    // @see docs/config-management.md#3.2.1
+    $this->assertExposedCodeComponents([], 'HIT', $request_options);
 
     // Modify a Code Component incorrectly (consistency-wise): 422.
     // @todo This currently returns a 200 response! https://www.drupal.org/i/3500043 will disallow PATCHing this if > 0 uses of this component exist.
@@ -686,6 +695,11 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     $request_options[RequestOptions::BODY] = self::encodeXBData($code_component_to_send);
     $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/js_component/test'), $request_options, 200, NULL, NULL, NULL, NULL);
     $this->assertSame($expected_component, $body);
+    // Confirm that the code component IS exposed, because `status` was just
+    // changed to `TRUE`.
+    // @see docs/config-management.md#3.2.1
+    $this->assertExposedCodeComponents(['js.test'], 'MISS', $request_options);
+    $this->assertExposedCodeComponents(['js.test'], 'HIT', $request_options);
 
     // Create an auto-save entry for this config entity, to verify that neither
     // the "list" nor the "individual" API responses tested here are affected by
@@ -709,6 +723,11 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     // Delete the 'test' Code Component via the XB HTTP API: 204.
     $body = $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/js_component/test'), [], 204, NULL, NULL, NULL, NULL);
     $this->assertNull($body);
+    // Confirm that the code component IS NOT exposed, because it no longer
+    // exists.
+    // @see docs/config-management.md#3.2.1
+    $this->assertExposedCodeComponents([], 'MISS', $request_options);
+    $this->assertExposedCodeComponents([], 'HIT', $request_options);
 
     // Re-retrieve list: 200, empty list. Dynamic Page Cache miss.
     $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:js_component_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
@@ -839,4 +858,46 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     ], json_decode((string) $response->getBody(), TRUE));
   }
 
+  private function assertExposedCodeComponents(array $expected, string $expected_dynamic_page_cache, array $request_options): void {
+    assert(in_array($expected_dynamic_page_cache, ['HIT', 'MISS'], TRUE));
+    $expected_contexts = [
+      'languages:language_content',
+      'languages:language_interface',
+      'route',
+      'theme',
+      'url.path',
+      'url.query_args',
+      'user.node_grants:view',
+      'user.permissions',
+      'user.roles:authenticated',
+      // The user_login_block is rendered as the anonymous user because for the
+      // authenticated user it is empty.
+      // @see \Drupal\experience_builder\Controller\ApiComponentsController::getCacheableClientSideInfo()
+      'user.roles:anonymous',
+    ];
+    $body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/component'), $request_options, 200, $expected_contexts, [
+      'CACHE_MISS_IF_UNCACHEABLE_HTTP_METHOD:form',
+      'config:component_list',
+      'config:core.extension',
+      'config:node_type_list',
+      'config:system.menu.account',
+      'config:system.site',
+      'config:system.theme',
+      'config:views.view.content_recent',
+      'config:views.view.who_s_new',
+      'http_response',
+      'local_task',
+      'node_list',
+      'user:1',
+      'user:2',
+      'user_list',
+    ], 'UNCACHEABLE (request policy)', $expected_dynamic_page_cache);
+    self:self::assertNotNull($body);
+    $component_config_entity_ids = array_keys($body);
+    self::assertSame(
+      $expected,
+      array_values(array_filter($component_config_entity_ids, fn (string $id) => str_starts_with($id, 'js.'))),
+    );
+  }
+
 }
-- 
GitLab


From 04bcd51cf284434cd38afa254d7efc8c9afa6976 Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Tue, 25 Feb 2025 18:45:15 +0100
Subject: [PATCH 2/5] Both kernel and integration test coverage for how
 `status` changes SHOUDL work.

---
 .../Functional/XbConfigEntityHttpApiTest.php  | 28 ++++++++++++-
 .../Config/JavascriptComponentStorageTest.php | 39 ++++++++++++++++---
 2 files changed, 60 insertions(+), 7 deletions(-)

diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php
index 91160b35fb..ec725f017b 100644
--- a/tests/src/Functional/XbConfigEntityHttpApiTest.php
+++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php
@@ -6,6 +6,7 @@ namespace Drupal\Tests\experience_builder\Functional;
 
 use Drupal\Core\Url;
 use Drupal\experience_builder\Entity\AssetLibrary;
+use Drupal\experience_builder\Entity\Component;
 use Drupal\experience_builder\Entity\Pattern;
 use Drupal\system\Entity\Menu;
 use Drupal\Tests\experience_builder\Traits\ContribStrictConfigSchemaTestTrait;
@@ -688,8 +689,11 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     // @see docs/config-management.md#3.2.1
     $this->assertExposedCodeComponents([], 'HIT', $request_options);
 
-    // Modify a Code Component incorrectly (consistency-wise): 422.
-    // @todo This currently returns a 200 response! https://www.drupal.org/i/3500043 will disallow PATCHing this if > 0 uses of this component exist.
+    // Modify a Code Component correctly: 200.
+    // ⚠️This is changing it from `internal` → `exposed`, for the first time,
+    // this must trigger the creation a corresponding `Component` config entity.
+    $this->assertNull(Component::load('js.test'));
+    // @todo https://www.drupal.org/i/3500043 will disallow PATCHing this if > 0 uses of this component exist.
     $code_component_to_send['status'] = TRUE;
     $expected_component['status'] = TRUE;
     $request_options[RequestOptions::BODY] = self::encodeXBData($code_component_to_send);
@@ -698,9 +702,29 @@ class XbConfigEntityHttpApiTest extends HttpApiTestBase {
     // Confirm that the code component IS exposed, because `status` was just
     // changed to `TRUE`.
     // @see docs/config-management.md#3.2.1
+    $this->assertNotNull(Component::load('js.test'));
     $this->assertExposedCodeComponents(['js.test'], 'MISS', $request_options);
     $this->assertExposedCodeComponents(['js.test'], 'HIT', $request_options);
 
+    // Modify a Code Component correctly: 200.
+    // ⚠️This is changing it from `exposed` → `internal`. This must cause the
+    // `Component` config entity to continue to exist, but get its `status` to
+    // change to `FALSE`, and cause it to be omitted from the list of available
+    // components for the Content Creator.
+    // @todo https://www.drupal.org/i/3500043 will disallow PATCHing this if > 0 uses of this component exist.
+    $code_component_to_send['status'] = FALSE;
+    $expected_component['status'] = FALSE;
+    $request_options[RequestOptions::BODY] = self::encodeXBData($code_component_to_send);
+    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/js_component/test'), $request_options, 200, NULL, NULL, NULL, NULL);
+    $this->assertSame($expected_component, $body);
+    // Confirm that the code component IS exposed, because `status` was just
+    // changed to `TRUE`.
+    // @see docs/config-management.md#3.2.1
+    $this->assertNotNull(Component::load('js.test'));
+    $this->assertFalse(Component::load('js.test')->status());
+    $this->assertExposedCodeComponents([], 'MISS', $request_options);
+    $this->assertExposedCodeComponents([], 'HIT', $request_options);
+
     // Create an auto-save entry for this config entity, to verify that neither
     // the "list" nor the "individual" API responses tested here are affected by
     // it.
diff --git a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
index f6e5714860..911cc4462d 100644
--- a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
+++ b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
@@ -16,7 +16,8 @@ use Drupal\Tests\user\Traits\UserCreationTrait;
  * Tests JavascriptComponentStorage.
  *
  * @covers \Drupal\experience_builder\EntityHandlers\JavascriptComponentStorage
- * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent
+ * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::createConfigEntity
+ * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::updateConfigEntity
  * @group JavaScriptComponents
  * @group experience_builder
  */
@@ -74,9 +75,9 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
   }
 
   /**
-   * Covers component creation.
+   * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::createConfigEntity()
    */
-  public function testComponentEntityCreation(): void {
+  public function testComponentEntityCreation(): JavaScriptComponent {
     $js_component_id = $this->randomMachineName();
     $component_id = JsComponent::componentIdFromJavascriptComponentId($js_component_id);
     $reason_repository = $this->container->get(ComponentIncompatibilityReasonRepository::class);
@@ -174,10 +175,38 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
     $js_component->set('name', $new_name);
     $js_component->setProps($props)->save();
 
-    $component = \Drupal::entityTypeManager()->getStorage(Component::ENTITY_TYPE_ID)->loadUnchanged($component_id);
-    \assert($component instanceof ComponentInterface);
+    $component = $this->loadComponent($component_id);
     self::assertEquals($new_name, $component->label());
     self::assertEquals(['noodles', 'title'], \array_keys($component->getSettings()['prop_field_definitions']));
+
+    return $js_component;
+  }
+
+  /**
+   * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::updateConfigEntity()
+   * @depends testComponentEntityCreation
+   */
+  public function testComponentEntityUpdate(JavaScriptComponent $js_component): void {
+    $component_id = JsComponent::componentIdFromJavascriptComponentId($js_component->id());
+
+    // Name should carry over.
+    $new_name = $js_component->label() . ' — updated';
+    $js_component->set('name', $new_name)->save();
+    $this->assertSame($new_name, $this->loadComponent($component_id)->label());
+
+    // Status should carry over.
+    $this->assertTrue($js_component->status());
+    $this->assertTrue($this->loadComponent($component_id)->status());
+    $js_component->disable()->save();
+    $this->assertFalse($js_component->status());
+    $this->assertFalse($this->loadComponent($component_id)->status());
+    $js_component->enable()->save();
+    $this->assertTrue($js_component->status());
+    $this->assertTrue($this->loadComponent($component_id)->status());
+  }
+
+  private function loadComponent(string $id): Component {
+    return \Drupal::entityTypeManager()->getStorage(Component::ENTITY_TYPE_ID)->loadUnchanged($id);
   }
 
 }
-- 
GitLab


From c1d2b19198bdacaf64a179123cf4f20afb974c76 Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Tue, 25 Feb 2025 18:53:19 +0100
Subject: [PATCH 3/5] `phpstan`

---
 tests/src/Kernel/Config/JavascriptComponentStorageTest.php | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
index 911cc4462d..856507576c 100644
--- a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
+++ b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\experience_builder\Kernel\Config;
 
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\experience_builder\ComponentIncompatibilityReasonRepository;
 use Drupal\experience_builder\Entity\Component;
 use Drupal\experience_builder\Entity\ComponentInterface;
@@ -187,6 +188,7 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
    * @depends testComponentEntityCreation
    */
   public function testComponentEntityUpdate(JavaScriptComponent $js_component): void {
+    assert(is_string($js_component->id()));
     $component_id = JsComponent::componentIdFromJavascriptComponentId($js_component->id());
 
     // Name should carry over.
@@ -206,7 +208,10 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
   }
 
   private function loadComponent(string $id): Component {
-    return \Drupal::entityTypeManager()->getStorage(Component::ENTITY_TYPE_ID)->loadUnchanged($id);
+    // @phpstan-ignore-next-line
+    return $this->container->get(EntityTypeManagerInterface::class)
+      ->getStorage(Component::ENTITY_TYPE_ID)
+      ->loadUnchanged($id);
   }
 
 }
-- 
GitLab


From 408738c93bd28d19c378b7a4d626b87432c464ef Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Tue, 25 Feb 2025 19:31:45 +0100
Subject: [PATCH 4/5] =?UTF-8?q?Apparently=20PHPUnit=20on=20my=20local=20ma?=
 =?UTF-8?q?chine=20can=20do=20more=20than=20on=20GitLab=20CI=20?=
 =?UTF-8?q?=F0=9F=A4=B7=E2=80=8D=E2=99=82=EF=B8=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/Kernel/Config/JavascriptComponentStorageTest.php  | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
index 856507576c..da04409b6f 100644
--- a/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
+++ b/tests/src/Kernel/Config/JavascriptComponentStorageTest.php
@@ -78,7 +78,7 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
   /**
    * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::createConfigEntity()
    */
-  public function testComponentEntityCreation(): JavaScriptComponent {
+  public function testComponentEntityCreation(): array {
     $js_component_id = $this->randomMachineName();
     $component_id = JsComponent::componentIdFromJavascriptComponentId($js_component_id);
     $reason_repository = $this->container->get(ComponentIncompatibilityReasonRepository::class);
@@ -180,14 +180,16 @@ final class JavascriptComponentStorageTest extends AssetLibraryStorageTest {
     self::assertEquals($new_name, $component->label());
     self::assertEquals(['noodles', 'title'], \array_keys($component->getSettings()['prop_field_definitions']));
 
-    return $js_component;
+    return $js_component->toArray();
   }
 
   /**
    * @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\JsComponent::updateConfigEntity()
    * @depends testComponentEntityCreation
    */
-  public function testComponentEntityUpdate(JavaScriptComponent $js_component): void {
+  public function testComponentEntityUpdate(array $js_component_values): void {
+    $js_component = JavaScriptComponent::create($js_component_values);
+    $js_component->save();
     assert(is_string($js_component->id()));
     $component_id = JsComponent::componentIdFromJavascriptComponentId($js_component->id());
 
-- 
GitLab


From 6a06dfcbdfb318c1f62e199322c85f7a444017fc Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Tue, 25 Feb 2025 18:46:33 +0100
Subject: [PATCH 5/5] Fix.

---
 src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php
index 0f4c596109..281265d44e 100644
--- a/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php
+++ b/src/Plugin/ExperienceBuilder/ComponentSource/JsComponent.php
@@ -254,6 +254,7 @@ final class JsComponent extends GeneratedFieldExplicitInputUxComponentSourceBase
     $label_key = $component->getEntityType()->getKey('label');
     assert(is_string($label_key));
     $component->set($label_key, $js_component->label());
+    $component->setStatus($js_component->status());
     try {
       $ephemeral_sdc_component = self::buildEphemeralSdcPluginInstance($js_component);
     }
-- 
GitLab