Files
michaelschiemer/docs/claude/livecomponents-test-harness.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

13 KiB

LiveComponents Test Harness

Comprehensive test harness für LiveComponents mit ComponentTestCase trait und ComponentFactory.

Übersicht

Der Test-Harness bietet:

  • ComponentTestCase trait: Umfassende Test-Helper-Methoden
  • ComponentFactory: Builder-Pattern für Test-Component-Erstellung
  • Automatische Setup: CSRF, Authorization, State Validation Integration
  • Assertions: State, Action, Authorization und Event Assertions

Quick Start

<?php

use Tests\Framework\LiveComponents\ComponentFactory;
use Tests\Framework\LiveComponents\ComponentTestCase;

// Use ComponentTestCase trait
uses(ComponentTestCase::class);

// Setup before each test
beforeEach(function () {
    $this->setUpComponentTest();
});

it('executes component action', function () {
    $component = ComponentFactory::counter(initialCount: 5);

    $result = $this->callAction($component, 'increment');

    expect($result->state->data['count'])->toBe(6);
});

ComponentTestCase Trait

Setup Methode

setUpComponentTest() - Initialisiert Test-Environment:

  • Erstellt Session mit CSRF-Token-Generator
  • Initialisiert LiveComponentHandler mit allen Dependencies (CSRF, Auth, Validation)
  • Setzt EventDispatcher, AuthorizationChecker, StateValidator, SchemaCache auf
beforeEach(function () {
    $this->setUpComponentTest();
});

Authentication Helper

actingAs(array $permissions = [], int $userId = 1) - Mock authenticated user:

$this->actingAs(['posts.edit', 'posts.delete']);

$result = $this->callAction($component, 'deletePost', ['id' => 123]);

Action Execution

callAction(LiveComponentContract $component, string $method, array $params = []) - Execute action with automatic CSRF:

$component = ComponentFactory::counter();

// Automatic CSRF token generation
$result = $this->callAction($component, 'increment');

// With parameters
$result = $this->callAction($component, 'addItem', ['item' => 'New Task']);

Action Assertions

assertActionExecutes() - Assert action executes successfully:

$result = $this->assertActionExecutes($component, 'increment');

expect($result->state->data['count'])->toBe(1);

assertActionThrows() - Assert action throws exception:

$component = ComponentFactory::make()
    ->withId('error:component')
    ->withState(['data' => 'test'])
    ->withAction('fail', function() {
        throw new \RuntimeException('Expected error');
    })
    ->create();

$this->assertActionThrows($component, 'fail', \RuntimeException::class);

assertActionRequiresAuth() - Assert action requires authentication:

// Note: Requires real component class with #[RequiresPermission] attribute
// ComponentFactory closures don't support attributes
$this->assertActionRequiresAuth($component, 'protectedAction');

assertActionRequiresPermission() - Assert action requires specific permission:

$this->actingAs(['posts.view']); // Insufficient permission

$this->assertActionRequiresPermission(
    $component,
    'deletePost',
    ['posts.view'] // Should fail with only 'view' permission
);

State Assertions

assertStateEquals(ComponentUpdate $result, array $expected) - Assert state matches expected:

$result = $this->callAction($component, 'increment');

$this->assertStateEquals($result, ['count' => 1]);

assertStateHas(ComponentUpdate $result, string $key) - Assert state has key:

$this->assertStateHas($result, 'items');

assertStateValidates(ComponentUpdate $result) - Assert state passes validation:

$result = $this->callAction($component, 'updateData', ['value' => 'test']);

$this->assertStateValidates($result);

getStateValue(ComponentUpdate $result, string $key) - Get specific state value:

$count = $this->getStateValue($result, 'count');

expect($count)->toBe(5);

Event Assertions

assertEventDispatched(ComponentUpdate $result, string $eventName) - Assert event was dispatched:

$result = $this->callAction($component, 'submitForm');

$this->assertEventDispatched($result, 'form:submitted');

assertNoEventsDispatched(ComponentUpdate $result) - Assert no events were dispatched:

$result = $this->callAction($component, 'increment');

$this->assertNoEventsDispatched($result);

assertEventCount(ComponentUpdate $result, int $count) - Assert event count:

$result = $this->callAction($component, 'bulkOperation');

$this->assertEventCount($result, 3);

ComponentFactory

Builder Pattern

ComponentFactory::make() - Start builder:

$component = ComponentFactory::make()
    ->withId('posts:manager')
    ->withState(['posts' => [], 'count' => 0])
    ->withAction('addPost', function(string $title) {
        $this->state['posts'][] = $title;
        $this->state['count']++;
        return ComponentData::fromArray($this->state);
    })
    ->create();

Builder Methods

  • withId(string $id) - Set component ID
  • withState(array $state) - Set initial state (cannot be empty!)
  • withAction(string $name, callable $handler) - Add custom action
  • withTemplate(string $template) - Set template name
  • create() - Create component instance

Pre-configured Components

ComponentFactory::counter(int $initialCount = 0) - Counter component:

$component = ComponentFactory::counter(initialCount: 5);

// Actions: increment, decrement, reset
$result = $this->callAction($component, 'increment');

expect($result->state->data['count'])->toBe(6);

ComponentFactory::list(array $initialItems = []) - List component:

$component = ComponentFactory::list(['item1', 'item2']);

// Actions: addItem, removeItem, clear
$result = $this->callAction($component, 'addItem', ['item' => 'item3']);

expect($result->state->data['items'])->toHaveCount(3);

Integration Features

Automatic CSRF Protection

// CSRF token automatically generated and validated
$result = $this->callAction($component, 'action');

// CSRF token: 'livecomponent:{componentId}' form ID

Automatic State Validation

// State automatically validated against derived schema
$result = $this->callAction($component, 'updateState');

// Schema derived on first getData() call
// Cached for subsequent validations

Authorization Integration

// Mock authenticated user with permissions
$this->actingAs(['admin.access']);

// Authorization automatically checked for #[RequiresPermission] attributes
$result = $this->callAction($component, 'adminAction');

Best Practices

State Must Not Be Empty

// ❌ Empty state causes schema derivation error
$component = ComponentFactory::make()
    ->withState([])
    ->create();

// ✅ Always provide at least one state field
$component = ComponentFactory::make()
    ->withState(['initialized' => true])
    ->create();

Authorization Testing Requires Real Classes

// ❌ Closures don't support attributes for authorization
$component = ComponentFactory::make()
    ->withAction('protectedAction',
        #[RequiresPermission('admin')] // Attribute wird ignoriert
        function() { }
    )
    ->create();

// ✅ Use real component class for authorization testing
final readonly class TestComponent implements LiveComponentContract
{
    #[RequiresPermission('admin')]
    public function protectedAction(): ComponentData
    {
        // Implementation
    }
}

Action Closures Have Access to Component State

$component = ComponentFactory::make()
    ->withState(['count' => 0])
    ->withAction('increment', function() {
        // $this->state available via closure binding
        $this->state['count']++;
        return ComponentData::fromArray($this->state);
    })
    ->create();

Multiple Actions in Sequence

it('handles multiple actions', function () {
    $component = ComponentFactory::counter();

    $result1 = $this->callAction($component, 'increment');
    $result2 = $this->callAction($component, 'increment');
    $result3 = $this->callAction($component, 'decrement');

    // Note: Component state is immutable
    // Each call returns new state, doesn't mutate original
    expect($result1->state->data['count'])->toBe(1);
    expect($result2->state->data['count'])->toBe(2);
    expect($result3->state->data['count'])->toBe(1);
});

Test Organization

tests/
├── Framework/LiveComponents/
│   ├── ComponentTestCase.php     # Trait with helper methods
│   └── ComponentFactory.php      # Builder for test components
└── Feature/Framework/LiveComponents/
    ├── TestHarnessDemo.php       # Demo of all features
    ├── SimpleTestHarnessTest.php # Simple examples
    └── ExceptionTestHarnessTest.php # Exception handling examples

Complete Example

<?php

use Tests\Framework\LiveComponents\ComponentFactory;
use Tests\Framework\LiveComponents\ComponentTestCase;

uses(ComponentTestCase::class);

beforeEach(function () {
    $this->setUpComponentTest();
});

describe('Shopping Cart Component', function () {
    it('adds items to cart', function () {
        $component = ComponentFactory::make()
            ->withId('shopping-cart')
            ->withState(['items' => [], 'total' => 0])
            ->withAction('addItem', function(string $product, int $price) {
                $this->state['items'][] = ['product' => $product, 'price' => $price];
                $this->state['total'] += $price;
                return ComponentData::fromArray($this->state);
            })
            ->create();

        $result = $this->callAction($component, 'addItem', [
            'product' => 'Laptop',
            'price' => 999
        ]);

        $this->assertStateHas($result, 'items');
        expect($result->state->data['items'])->toHaveCount(1);
        expect($result->state->data['total'])->toBe(999);
    });

    it('requires authentication for checkout', function () {
        $component = ComponentFactory::make()
            ->withId('shopping-cart')
            ->withState(['items' => [['product' => 'Laptop', 'price' => 999]]])
            ->withAction('checkout', function() {
                // Checkout logic
                return ComponentData::fromArray($this->state);
            })
            ->create();

        // Without authentication
        // Note: For authorization testing, use real component classes

        // With authentication
        $this->actingAs(['checkout.access']);
        $result = $this->assertActionExecutes($component, 'checkout');
    });
});

Known Limitations

1. Closure Attributes

Attributes on closures passed to withAction() are not supported for authorization checks:

// ❌ Doesn't work - attribute ignored
$component = ComponentFactory::make()
    ->withAction('protectedAction',
        #[RequiresPermission('admin')]
        function() { }
    )
    ->create();

Workaround: Create real component class for authorization testing.

2. Empty State Not Allowed

Components must have at least one state field for schema derivation:

// ❌ Throws InvalidArgumentException: 'Schema cannot be empty'
$component = ComponentFactory::make()
    ->withState([])
    ->create();

// ✅ Provide at least one field
$component = ComponentFactory::make()
    ->withState(['initialized' => true])
    ->create();

3. Magic Method Reflection

ComponentFactory uses __call() for actions, which limits reflection-based parameter analysis. The handler falls back to direct parameter passing for magic methods.

Performance Considerations

  • Schema Caching: Schema derived once per component class and cached
  • CSRF Generation: CSRF token generated per test, not reused
  • Session State: Session state reset in setUpComponentTest()
  • Event Dispatcher: Events collected per action call, not persisted

Troubleshooting

"Method not found" Error

BadMethodCallException: Method increment not found on component

Fix: Ensure method_exists() check supports __call() magic methods:

// LiveComponentHandler checks both real and magic methods
if (!method_exists($component, $method) && !is_callable([$component, $method])) {
    throw new \BadMethodCallException(...);
}

"Schema cannot be empty" Error

InvalidArgumentException: Schema cannot be empty

Fix: Provide non-empty state:

// ❌ Empty state
->withState([])

// ✅ Non-empty state
->withState(['data' => 'test'])

Reflection Exception for Actions

ReflectionException: Method increment() does not exist

Fix: Handler catches ReflectionException and falls back to direct call:

try {
    $reflection = new \ReflectionMethod($component, $method);
    // Parameter analysis
} catch (\ReflectionException $e) {
    // Direct call for magic methods
    return $component->$method(...$params->toArray());
}

Summary

Der Test-Harness bietet:

  • Einfache Component-Erstellung via ComponentFactory
  • Umfassende Assertions für State, Actions, Events
  • Automatische Integration mit CSRF, Auth, Validation
  • Flexible Test-Components via Builder Pattern
  • Pre-configured Components (Counter, List)
  • ⚠️ Known Limitations mit Closure-Attributes

Framework-Integration: Vollständig integriert mit LiveComponentHandler, StateValidator, AuthorizationChecker und EventDispatcher.