mount('counter:test', ['count' => 5]) * ->call('increment') * ->seeStateKey('count', 6) * ->seeHtmlHas('Count: 6'); * } * } * ``` */ abstract class LiveComponentTestCase extends TestCase { protected ComponentRegistry $registry; protected LiveComponentHandler $handler; protected LiveComponentRenderer $renderer; protected FragmentRenderer $fragmentRenderer; protected ?LiveComponentContract $currentComponent = null; protected ?ComponentUpdate $lastUpdate = null; protected string|array $lastHtml = ''; protected array $lastState = []; protected array $lastEvents = []; protected function setUp(): void { parent::setUp(); $container = $this->getContainer(); $this->registry = $container->get(ComponentRegistry::class); $this->handler = $container->get(LiveComponentHandler::class); $this->renderer = $container->get(LiveComponentRenderer::class); $this->fragmentRenderer = $container->get(FragmentRenderer::class); } /** * Get DI Container instance */ protected function getContainer(): Container { static $container = null; if ($container === null) { // Bootstrap container for tests require_once __DIR__ . '/../../../../bootstrap.php'; $container = \createTestContainer(); } return $container; } /** * Mount a component with initial state * * @param string $componentId Component ID (e.g., 'counter:test') * @param array $initialData Initial component data * @return $this */ protected function mount(string $componentId, array $initialData = []): self { $this->currentComponent = $this->registry->resolve( ComponentId::fromString($componentId), $initialData ); $this->lastHtml = $this->renderer->render($this->currentComponent); $this->lastState = $this->currentComponent->state->toArray(); $this->lastEvents = []; $this->lastUpdate = null; return $this; } /** * Call an action on the current component * * @param string $action Action name * @param array $params Action parameters * @param array $fragments Optional fragment names to extract * @return $this */ protected function call(string $action, array $params = [], array $fragments = []): self { if ($this->currentComponent === null) { throw new \RuntimeException('No component mounted. Call mount() first.'); } // Create ActionParameters with CSRF token $actionParams = ActionParameters::fromArray($params); // Add CSRF token if not present if (!$actionParams->hasCsrfToken()) { $csrfToken = $this->registry->generateCsrfToken($this->currentComponent->id); $actionParams = ActionParameters::fromArray(array_merge($params, [ 'csrf_token' => $csrfToken->toString() ])); } // Execute action $this->lastUpdate = $this->handler->handle( $this->currentComponent, $action, $actionParams ); // Resolve component with updated state $this->currentComponent = $this->registry->resolve( $this->currentComponent->id, $this->lastUpdate->state->data ); // Render updated component if (!empty($fragments)) { $fragmentCollection = $this->fragmentRenderer->renderFragments( $this->currentComponent, $fragments ); $this->lastHtml = []; foreach ($fragmentCollection as $fragment) { $this->lastHtml[$fragment->name] = $fragment->content; } } else { $this->lastHtml = $this->renderer->render($this->currentComponent); } $this->lastState = $this->currentComponent->state->toArray(); $this->lastEvents = $this->lastUpdate->events; return $this; } /** * Assert HTML contains the given content * * @param string $needle Content to search for * @return $this */ protected function seeHtmlHas(string $needle): self { $html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml; $this->assertStringContainsString($needle, $html, "Expected HTML to contain '{$needle}'"); return $this; } /** * Assert HTML does not contain the given content * * @param string $needle Content to search for * @return $this */ protected function seeHtmlNotHas(string $needle): self { $html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml; $this->assertStringNotContainsString($needle, $html, "Expected HTML not to contain '{$needle}'"); return $this; } /** * Assert state equals expected state * * @param array $expectedState Expected state array * @return $this */ protected function seeStateEquals(array $expectedState): self { $this->assertEquals($expectedState, $this->lastState, 'State does not match expected'); return $this; } /** * Assert specific state key has expected value * * @param string $key State key * @param mixed $value Expected value * @return $this */ protected function seeStateKey(string $key, mixed $value): self { $this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found"); $this->assertEquals($value, $this->lastState[$key], "State key '{$key}' does not match expected value"); return $this; } /** * Assert state key exists * * @param string $key State key * @return $this */ protected function seeStateHasKey(string $key): self { $this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found"); return $this; } /** * Assert event was dispatched * * @param string $eventName Event name * @param array|null $expectedPayload Optional expected payload * @return $this */ protected function seeEventDispatched(string $eventName, ?array $expectedPayload = null): self { $found = false; foreach ($this->lastEvents as $event) { if ($event->name === $eventName) { $found = true; if ($expectedPayload !== null) { $actualPayload = $event->payload->toArray(); $this->assertEquals($expectedPayload, $actualPayload, "Event '{$eventName}' payload does not match"); } break; } } $this->assertTrue($found, "Event '{$eventName}' was not dispatched"); return $this; } /** * Assert event was not dispatched * * @param string $eventName Event name * @return $this */ protected function seeEventNotDispatched(string $eventName): self { foreach ($this->lastEvents as $event) { if ($event->name === $eventName) { $this->fail("Event '{$eventName}' was dispatched but should not have been"); } } return $this; } /** * Assert fragment contains content * * @param string $fragmentName Fragment name * @param string $content Expected content * @return $this */ protected function seeFragment(string $fragmentName, string $content): self { if (!is_array($this->lastHtml)) { $this->fail('Fragments not requested. Use call() with fragments parameter.'); } $this->assertArrayHasKey($fragmentName, $this->lastHtml, "Fragment '{$fragmentName}' not found"); $this->assertStringContainsString($content, $this->lastHtml[$fragmentName], "Fragment '{$fragmentName}' does not contain expected content"); return $this; } /** * Assert component instance * * @param string $expectedClass Expected component class * @return $this */ protected function assertComponent(string $expectedClass): self { $this->assertNotNull($this->currentComponent, 'No component mounted'); $this->assertInstanceOf($expectedClass, $this->currentComponent); return $this; } /** * Get current component instance */ protected function getComponent(): ?LiveComponentContract { return $this->currentComponent; } /** * Get current HTML */ protected function getHtml(): string|array { return $this->lastHtml; } /** * Get current state */ protected function getState(): array { return $this->lastState; } /** * Get last dispatched events */ protected function getEvents(): array { return $this->lastEvents; } }