fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\TestHarness;
|
||||
|
||||
/**
|
||||
* Snapshot Testing Utilities for LiveComponents
|
||||
*
|
||||
* Provides snapshot comparison for component render output with whitespace normalization.
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* $snapshot = ComponentSnapshotTest::createSnapshot('counter-initial', $html);
|
||||
* ComponentSnapshotTest::assertMatchesSnapshot($html, 'counter-initial');
|
||||
* ```
|
||||
*/
|
||||
final readonly class ComponentSnapshotTest
|
||||
{
|
||||
private const SNAPSHOT_DIR = __DIR__ . '/../../../../tests/snapshots/livecomponents';
|
||||
|
||||
/**
|
||||
* Normalize HTML for snapshot comparison
|
||||
*
|
||||
* Removes:
|
||||
* - Extra whitespace
|
||||
* - Line breaks
|
||||
* - Multiple spaces
|
||||
* - CSRF tokens (dynamic)
|
||||
* - Timestamps (dynamic)
|
||||
*/
|
||||
public static function normalizeHtml(string $html): string
|
||||
{
|
||||
// Remove CSRF tokens (they're dynamic)
|
||||
$html = preg_replace('/data-csrf-token="[^"]*"/', 'data-csrf-token="[CSRF_TOKEN]"', $html);
|
||||
|
||||
// Remove timestamps (they're dynamic)
|
||||
$html = preg_replace('/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', '[TIMESTAMP]', $html);
|
||||
|
||||
// Remove component IDs with random parts (keep structure)
|
||||
$html = preg_replace('/data-component-id="([^:]+):[^"]*"/', 'data-component-id="$1:[ID]"', $html);
|
||||
|
||||
// Normalize whitespace
|
||||
$html = preg_replace('/\s+/', ' ', $html);
|
||||
$html = trim($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update snapshot
|
||||
*
|
||||
* @param string $snapshotName Snapshot name (without extension)
|
||||
* @param string $html HTML to snapshot
|
||||
* @return string Path to snapshot file
|
||||
*/
|
||||
public static function createSnapshot(string $snapshotName, string $html): string
|
||||
{
|
||||
self::ensureSnapshotDir();
|
||||
|
||||
$normalized = self::normalizeHtml($html);
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
|
||||
file_put_contents($filePath, $normalized);
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert HTML matches snapshot
|
||||
*
|
||||
* @param string $html HTML to compare
|
||||
* @param string $snapshotName Snapshot name
|
||||
* @param bool $updateSnapshot If true, update snapshot instead of asserting
|
||||
* @throws \PHPUnit\Framework\AssertionFailedError If snapshot doesn't match
|
||||
*/
|
||||
public static function assertMatchesSnapshot(
|
||||
string $html,
|
||||
string $snapshotName,
|
||||
bool $updateSnapshot = false
|
||||
): void {
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
$normalized = self::normalizeHtml($html);
|
||||
|
||||
if ($updateSnapshot || !file_exists($filePath)) {
|
||||
self::createSnapshot($snapshotName, $html);
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = file_get_contents($filePath);
|
||||
|
||||
if ($normalized !== $expected) {
|
||||
$diff = self::generateDiff($expected, $normalized);
|
||||
|
||||
throw new \PHPUnit\Framework\AssertionFailedError(
|
||||
"Snapshot '{$snapshotName}' does not match.\n\n" .
|
||||
"Expected:\n{$expected}\n\n" .
|
||||
"Actual:\n{$normalized}\n\n" .
|
||||
"Diff:\n{$diff}\n\n" .
|
||||
"To update snapshot, set updateSnapshot=true or delete: {$filePath}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot file path
|
||||
*/
|
||||
private static function getSnapshotPath(string $snapshotName): string
|
||||
{
|
||||
return self::SNAPSHOT_DIR . '/' . $snapshotName . '.snapshot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure snapshot directory exists
|
||||
*/
|
||||
private static function ensureSnapshotDir(): void
|
||||
{
|
||||
if (!is_dir(self::SNAPSHOT_DIR)) {
|
||||
mkdir(self::SNAPSHOT_DIR, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diff between expected and actual
|
||||
*/
|
||||
private static function generateDiff(string $expected, string $actual): string
|
||||
{
|
||||
$expectedLines = explode("\n", $expected);
|
||||
$actualLines = explode("\n", $actual);
|
||||
|
||||
$diff = [];
|
||||
$maxLines = max(count($expectedLines), count($actualLines));
|
||||
|
||||
for ($i = 0; $i < $maxLines; $i++) {
|
||||
$expectedLine = $expectedLines[$i] ?? null;
|
||||
$actualLine = $actualLines[$i] ?? null;
|
||||
|
||||
if ($expectedLine === $actualLine) {
|
||||
$diff[] = " {$i}: {$expectedLine}";
|
||||
} else {
|
||||
if ($expectedLine !== null) {
|
||||
$diff[] = "- {$i}: {$expectedLine}";
|
||||
}
|
||||
if ($actualLine !== null) {
|
||||
$diff[] = "+ {$i}: {$actualLine}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all snapshots
|
||||
*
|
||||
* @return array<string> Snapshot names
|
||||
*/
|
||||
public static function listSnapshots(): array
|
||||
{
|
||||
self::ensureSnapshotDir();
|
||||
|
||||
$snapshots = [];
|
||||
$files = glob(self::SNAPSHOT_DIR . '/*.snapshot');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$snapshots[] = basename($file, '.snapshot');
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete snapshot
|
||||
*/
|
||||
public static function deleteSnapshot(string $snapshotName): bool
|
||||
{
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
return unlink($filePath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\TestHarness;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\LiveComponentHandler;
|
||||
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
||||
use App\Framework\View\LiveComponentRenderer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Base Test Case for LiveComponent Tests
|
||||
*
|
||||
* Provides fluent API for testing LiveComponents:
|
||||
* - mount() - Mount component with initial state
|
||||
* - call() - Call action on component
|
||||
* - seeHtmlHas() - Assert HTML contains content
|
||||
* - seeStateEquals() - Assert state matches expected
|
||||
* - seeStateKey() - Assert specific state key
|
||||
* - seeEventDispatched() - Assert event was dispatched
|
||||
* - seeFragment() - Assert fragment contains content
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* class MyComponentTest extends LiveComponentTestCase
|
||||
* {
|
||||
* public function test_increments_counter(): void
|
||||
* {
|
||||
* $this->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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user