From 4b06ebd37bae7b6dabc206f86f06cef59fdf06cd Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 29 Apr 2023 12:42:35 -0400 Subject: [PATCH] feat(live): add test helpers --- src/LiveComponent/CHANGELOG.md | 4 + .../src/Test/InteractsWithLiveComponents.php | 46 ++++++ .../src/Test/TestLiveComponent.php | 138 ++++++++++++++++++ .../Test/InteractsWithLiveComponentsTest.php | 78 ++++++++++ 4 files changed, 266 insertions(+) create mode 100644 src/LiveComponent/src/Test/InteractsWithLiveComponents.php create mode 100644 src/LiveComponent/src/Test/TestLiveComponent.php create mode 100644 src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index f9b8286e48a..595ff9ff409 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.11.0 + +- Add helper for testing live components. + ## 2.9.0 - Add support for symfony/asset-mapper diff --git a/src/LiveComponent/src/Test/InteractsWithLiveComponents.php b/src/LiveComponent/src/Test/InteractsWithLiveComponents.php new file mode 100644 index 00000000000..37351d0c982 --- /dev/null +++ b/src/LiveComponent/src/Test/InteractsWithLiveComponents.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Test; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\ComponentFactory; + +/** + * @author Kevin Bond + */ +trait InteractsWithLiveComponents +{ + protected function createLiveComponent(string $name, array $data = []): TestLiveComponent + { + if (!$this instanceof KernelTestCase) { + throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class)); + } + + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $metadata = $factory->metadataFor($name); + + if (!$metadata->get('live')) { + throw new \LogicException(sprintf('The "%s" component is not a live component.', $name)); + } + + return new TestLiveComponent( + $metadata, + $data, + $factory, + self::getContainer()->get('test.client'), + self::getContainer()->get('ux.live_component.component_hydrator'), + self::getContainer()->get('ux.live_component.metadata_factory'), + self::getContainer()->get('router'), + ); + } +} diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php new file mode 100644 index 00000000000..fd780698f82 --- /dev/null +++ b/src/LiveComponent/src/Test/TestLiveComponent.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Test; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentMetadata; +use Symfony\UX\TwigComponent\MountedComponent; +use Symfony\UX\TwigComponent\Test\RenderedComponent; + +/** + * @author Kevin Bond + */ +final class TestLiveComponent +{ + private string $rendered; + private array $props; + private string $csrfToken; + private object $component; + + /** + * @internal + */ + public function __construct( + private ComponentMetadata $metadata, + array $data, + private ComponentFactory $factory, + private KernelBrowser $client, + private LiveComponentHydrator $hydrator, + private LiveComponentMetadataFactory $metadataFactory, + private UrlGeneratorInterface $router, + ) { + $this->client->catchExceptions(false); + + $mounted = $this->factory->create($this->metadata->getName(), $data); + $props = $this->hydrator->dehydrate( + $mounted->getComponent(), + $mounted->getAttributes(), + $this->metadataFactory->getMetadata($mounted->getName()) + ); + + $this->client->request('GET', $this->router->generate( + $this->metadata->get('route'), + [ + '_live_component' => $this->metadata->getName(), + 'props' => json_encode($props->getProps()), + ] + )); + + $this->updateState(); + } + + public function render(): RenderedComponent + { + return new RenderedComponent($this->rendered); + } + + public function component(): object + { + if (isset($this->component)) { + return $this->component; + } + + $component = $this->factory->get($this->metadata->getName()); + $componentAttributes = $this->hydrator->hydrate( + $component, + $this->props, + [], + $this->metadataFactory->getMetadata($this->metadata->getName()), + ); + + return $this->component = (new MountedComponent($this->metadata->getName(), $component, $componentAttributes))->getComponent(); + } + + /** + * @param array $arguments + */ + public function call(string $action, array $arguments = []): self + { + return $this->request(['args' => $arguments], $action); + } + + /** + * @param array $arguments + */ + public function emit(string $event, array $arguments = []): self + { + return $this->call($event, $arguments); + } + + public function set(string $prop, mixed $value): self + { + return $this->request(['updated' => [$prop => $value]]); + } + + private function request(array $content = [], ?string $action = null): self + { + $this->client->request( + 'POST', + $this->router->generate( + $this->metadata->get('route'), + array_filter([ + '_live_component' => $this->metadata->getName(), + '_live_action' => $action, + ]) + ), + parameters: ['data' => json_encode(array_merge($content, ['props' => $this->props]))], + server: ['HTTP_X_CSRF_TOKEN' => $this->csrfToken], + ); + + return $this->updateState(); + } + + private function updateState(): self + { + $crawler = $this->client->getCrawler(); + + $this->props = json_decode($crawler->filter('[data-live-props-value]')->attr('data-live-props-value'), true, flags: \JSON_THROW_ON_ERROR); + $this->csrfToken = $crawler->filter('[data-live-csrf-value]')->attr('data-live-csrf-value'); + $this->rendered = $this->client->getResponse()->getContent(); + + unset($this->component); + + return $this; + } +} diff --git a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php new file mode 100644 index 00000000000..8f0304ee53c --- /dev/null +++ b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php @@ -0,0 +1,78 @@ + + */ +final class InteractsWithLiveComponentsTest extends KernelTestCase +{ + use InteractsWithLiveComponents; + + public function testCanRenderInitialData(): void + { + $testComponent = $this->createLiveComponent('component2'); + + $this->assertStringContainsString('Count: 1', (string) $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + } + + public function testCanCreateWithClassString(): void + { + $testComponent = $this->createLiveComponent(Component2::class); + + $this->assertStringContainsString('Count: 1', (string) $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + } + + public function testCanCallLiveAction(): void + { + $testComponent = $this->createLiveComponent('component2'); + + $this->assertStringContainsString('Count: 1', $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + + $testComponent->call('increase'); + + $this->assertStringContainsString('Count: 2', $testComponent->render()); + $this->assertSame(2, $testComponent->component()->count); + } + + public function testCanCallLiveActionWithArguments(): void + { + $testComponent = $this->createLiveComponent('component6'); + + $this->assertStringContainsString('Arg1: not provided', $testComponent->render()); + $this->assertStringContainsString('Arg2: not provided', $testComponent->render()); + $this->assertStringContainsString('Arg3: not provided', $testComponent->render()); + $this->assertNull($testComponent->component()->arg1); + $this->assertNull($testComponent->component()->arg2); + $this->assertNull($testComponent->component()->arg3); + + $testComponent->call('inject', ['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']); + + $this->assertStringContainsString('Arg1: hello', $testComponent->render()); + $this->assertStringContainsString('Arg2: 666', $testComponent->render()); + $this->assertStringContainsString('Arg3: 33.3', $testComponent->render()); + $this->assertSame('hello', $testComponent->component()->arg1); + $this->assertSame(666, $testComponent->component()->arg2); + $this->assertSame(33.3, $testComponent->component()->arg3); + } + + public function testCanSetLiveProp(): void + { + $testComponent = $this->createLiveComponent('component_with_writable_props'); + + $this->assertStringContainsString('Count: 1', $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + + $testComponent->set('count', 100); + + $this->assertStringContainsString('Count: 100', $testComponent->render()); + $this->assertSame(100, $testComponent->component()->count); + } +}