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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,594 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Metrics\MetricType;
describe('ComponentMetricsCollector', function () {
beforeEach(function () {
$this->collector = new ComponentMetricsCollector();
});
describe('Render Metrics', function () {
it('records total renders with cached/uncached distinction', function () {
$this->collector->recordRender('user-stats', 45.5, false);
$this->collector->recordRender('user-stats', 12.3, true);
$this->collector->recordRender('dashboard', 78.9, false);
$metrics = $this->collector->getMetrics();
// Check total renders metrics exist (labels alphabetically sorted)
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=user-stats}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=user-stats}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=dashboard}');
// Verify counter values
expect($metrics['livecomponent_renders_total{cached=false,component_id=user-stats}']->value)->toBe(1.0);
expect($metrics['livecomponent_renders_total{cached=true,component_id=user-stats}']->value)->toBe(1.0);
});
it('records render duration histogram', function () {
$this->collector->recordRender('counter', 23.45, false);
$metrics = $this->collector->getMetrics();
// Check duration histogram exists (labels alphabetically sorted)
expect($metrics)->toHaveKey('livecomponent_render_duration_ms{cached=false,component_id=counter}');
$durationMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=counter}'];
expect($durationMetric->type)->toBe(MetricType::HISTOGRAM);
expect($durationMetric->value)->toBe(23.45);
expect($durationMetric->unit)->toBe('ms');
});
it('distinguishes between cached and uncached renders', function () {
$this->collector->recordRender('product-list', 120.0, false); // Uncached
$this->collector->recordRender('product-list', 5.0, true); // Cached
$metrics = $this->collector->getMetrics();
// Labels alphabetically sorted
$uncachedDuration = $metrics['livecomponent_render_duration_ms{cached=false,component_id=product-list}'];
$cachedDuration = $metrics['livecomponent_render_duration_ms{cached=true,component_id=product-list}'];
expect($uncachedDuration->value)->toBe(120.0);
expect($cachedDuration->value)->toBe(5.0);
});
});
describe('Action Metrics', function () {
it('records action executions with success/error status', function () {
$this->collector->recordAction('form', 'validate', 15.5, true);
$this->collector->recordAction('form', 'submit', 45.0, false);
$this->collector->recordAction('form', 'validate', 12.0, true);
$metrics = $this->collector->getMetrics();
// Check action totals
expect($metrics)->toHaveKey('livecomponent_actions_total{action=validate,component_id=form,status=success}');
expect($metrics)->toHaveKey('livecomponent_actions_total{action=submit,component_id=form,status=error}');
// Verify counter values
expect($metrics['livecomponent_actions_total{action=validate,component_id=form,status=success}']->value)->toBe(2.0);
expect($metrics['livecomponent_actions_total{action=submit,component_id=form,status=error}']->value)->toBe(1.0);
});
it('records action duration histogram', function () {
$this->collector->recordAction('cart', 'addItem', 34.56, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_action_duration_ms{action=addItem,component_id=cart}');
$durationMetric = $metrics['livecomponent_action_duration_ms{action=addItem,component_id=cart}'];
expect($durationMetric->type)->toBe(MetricType::HISTOGRAM);
expect($durationMetric->value)->toBe(34.56);
});
it('increments action error counter on failure', function () {
$this->collector->recordAction('payment', 'charge', 100.0, false);
$this->collector->recordAction('payment', 'charge', 95.0, false);
$metrics = $this->collector->getMetrics();
// Check error counter
expect($metrics)->toHaveKey('livecomponent_action_errors_total{action=charge,component_id=payment}');
expect($metrics['livecomponent_action_errors_total{action=charge,component_id=payment}']->value)->toBe(2.0);
});
it('does not increment error counter on success', function () {
$this->collector->recordAction('search', 'query', 50.0, true);
$metrics = $this->collector->getMetrics();
// Error counter should not exist
expect($metrics)->not->toHaveKey('livecomponent_action_errors_total{action=query,component_id=search}');
});
});
describe('Cache Metrics', function () {
it('records cache hits', function () {
$this->collector->recordCacheHit('user-profile', true);
$this->collector->recordCacheHit('user-profile', true);
$this->collector->recordCacheHit('dashboard', true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_cache_hits_total{component_id=user-profile}');
expect($metrics['livecomponent_cache_hits_total{component_id=user-profile}']->value)->toBe(2.0);
expect($metrics['livecomponent_cache_hits_total{component_id=dashboard}']->value)->toBe(1.0);
});
it('records cache misses', function () {
$this->collector->recordCacheHit('stats', false);
$this->collector->recordCacheHit('stats', false);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_cache_misses_total{component_id=stats}');
expect($metrics['livecomponent_cache_misses_total{component_id=stats}']->value)->toBe(2.0);
});
it('tracks mixed cache hits and misses', function () {
$this->collector->recordCacheHit('widget', true); // Hit
$this->collector->recordCacheHit('widget', false); // Miss
$this->collector->recordCacheHit('widget', true); // Hit
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_cache_hits_total{component_id=widget}']->value)->toBe(2.0);
expect($metrics['livecomponent_cache_misses_total{component_id=widget}']->value)->toBe(1.0);
});
});
describe('Event Metrics', function () {
it('records dispatched events', function () {
$this->collector->recordEventDispatched('chat', 'message.sent');
$this->collector->recordEventDispatched('chat', 'message.sent');
$this->collector->recordEventDispatched('chat', 'user.typing');
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_events_dispatched_total{component_id=chat,event=message.sent}');
expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=message.sent}']->value)->toBe(2.0);
expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=user.typing}']->value)->toBe(1.0);
});
it('records received events', function () {
$this->collector->recordEventReceived('notification', 'alert.received');
$this->collector->recordEventReceived('notification', 'alert.received');
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_events_received_total{component_id=notification,event=alert.received}');
expect($metrics['livecomponent_events_received_total{component_id=notification,event=alert.received}']->value)->toBe(2.0);
});
});
describe('Hydration Metrics', function () {
it('records hydration duration', function () {
$this->collector->recordHydration('interactive-map', 156.78);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_hydration_duration_ms{component_id=interactive-map}');
$metric = $metrics['livecomponent_hydration_duration_ms{component_id=interactive-map}'];
expect($metric->type)->toBe(MetricType::HISTOGRAM);
expect($metric->value)->toBe(156.78);
});
});
describe('Batch Metrics', function () {
it('records batch operations with success/failure counts', function () {
$this->collector->recordBatch(
operationCount: 5,
durationMs: 123.45,
successCount: 4,
failureCount: 1
);
$metrics = $this->collector->getMetrics();
// Check batch operation counter
expect($metrics)->toHaveKey('livecomponent_batch_operations_total{status=executed}');
expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(1.0);
// Check batch size histogram
expect($metrics)->toHaveKey('livecomponent_batch_size');
expect($metrics['livecomponent_batch_size']->value)->toBe(5.0);
// Check batch duration histogram
expect($metrics)->toHaveKey('livecomponent_batch_duration_ms');
expect($metrics['livecomponent_batch_duration_ms']->value)->toBe(123.45);
// Check success/failure counters
expect($metrics)->toHaveKey('livecomponent_batch_success_total');
expect($metrics['livecomponent_batch_success_total']->value)->toBe(4.0);
expect($metrics)->toHaveKey('livecomponent_batch_failure_total');
expect($metrics['livecomponent_batch_failure_total']->value)->toBe(1.0);
});
it('records multiple batch operations', function () {
$this->collector->recordBatch(3, 50.0, 3, 0);
$this->collector->recordBatch(7, 150.0, 5, 2);
$metrics = $this->collector->getMetrics();
// Batch operations counter should increment
expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(2.0);
// Success/failure totals should accumulate
expect($metrics['livecomponent_batch_success_total']->value)->toBe(8.0); // 3 + 5
expect($metrics['livecomponent_batch_failure_total']->value)->toBe(2.0);
});
it('handles batch with no failures', function () {
$this->collector->recordBatch(10, 200.0, 10, 0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_batch_success_total');
expect($metrics)->not->toHaveKey('livecomponent_batch_failure_total');
});
it('handles batch with no successes', function () {
$this->collector->recordBatch(5, 100.0, 0, 5);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_batch_failure_total');
expect($metrics)->not->toHaveKey('livecomponent_batch_success_total');
});
});
describe('Fragment Metrics', function () {
it('records fragment updates with count and duration', function () {
$this->collector->recordFragmentUpdate('dashboard', 3, 45.67);
$metrics = $this->collector->getMetrics();
// Check fragment update counter
expect($metrics)->toHaveKey('livecomponent_fragment_updates_total{component_id=dashboard}');
expect($metrics['livecomponent_fragment_updates_total{component_id=dashboard}']->value)->toBe(1.0);
// Check fragment count histogram
expect($metrics)->toHaveKey('livecomponent_fragment_count');
expect($metrics['livecomponent_fragment_count']->value)->toBe(3.0);
// Check fragment duration histogram
expect($metrics)->toHaveKey('livecomponent_fragment_duration_ms');
expect($metrics['livecomponent_fragment_duration_ms']->value)->toBe(45.67);
});
it('tracks multiple fragment updates', function () {
$this->collector->recordFragmentUpdate('editor', 2, 30.0);
$this->collector->recordFragmentUpdate('editor', 5, 60.0);
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_fragment_updates_total{component_id=editor}']->value)->toBe(2.0);
});
});
describe('Upload Metrics', function () {
it('records successful upload chunks', function () {
$this->collector->recordUploadChunk('session-123', 0, 45.6, true);
$this->collector->recordUploadChunk('session-123', 1, 42.3, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-123,status=success}');
expect($metrics['livecomponent_upload_chunks_total{session_id=session-123,status=success}']->value)->toBe(2.0);
expect($metrics)->toHaveKey('livecomponent_upload_chunk_duration_ms{session_id=session-123}');
});
it('records failed upload chunks', function () {
$this->collector->recordUploadChunk('session-456', 0, 100.0, false);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-456,status=error}');
expect($metrics['livecomponent_upload_chunks_total{session_id=session-456,status=error}']->value)->toBe(1.0);
});
it('records upload completion', function () {
$this->collector->recordUploadComplete('session-789', 5432.1, 10);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_uploads_completed_total{session_id=session-789}');
expect($metrics['livecomponent_uploads_completed_total{session_id=session-789}']->value)->toBe(1.0);
expect($metrics)->toHaveKey('livecomponent_upload_total_duration_ms');
expect($metrics['livecomponent_upload_total_duration_ms']->value)->toBe(5432.1);
expect($metrics)->toHaveKey('livecomponent_upload_chunk_count');
expect($metrics['livecomponent_upload_chunk_count']->value)->toBe(10.0);
});
});
describe('Summary Generation', function () {
it('generates summary with aggregated statistics', function () {
// Create variety of metrics
$this->collector->recordRender('comp-1', 50.0, false);
$this->collector->recordRender('comp-1', 10.0, true);
$this->collector->recordAction('comp-1', 'action1', 30.0, true);
$this->collector->recordAction('comp-1', 'action2', 40.0, false);
$this->collector->recordCacheHit('comp-1', true);
$this->collector->recordCacheHit('comp-1', true);
$this->collector->recordCacheHit('comp-1', false);
$this->collector->recordEventDispatched('comp-1', 'event1');
$summary = $this->collector->getSummary();
expect($summary)->toHaveKey('total_renders');
expect($summary)->toHaveKey('total_actions');
expect($summary)->toHaveKey('cache_hits');
expect($summary)->toHaveKey('cache_misses');
expect($summary)->toHaveKey('total_events');
expect($summary)->toHaveKey('action_errors');
expect($summary)->toHaveKey('cache_hit_rate');
expect($summary['total_renders'])->toBe(2);
expect($summary['total_actions'])->toBe(2);
expect($summary['cache_hits'])->toBe(2);
expect($summary['cache_misses'])->toBe(1);
expect($summary['total_events'])->toBe(1);
expect($summary['action_errors'])->toBe(1);
});
it('calculates cache hit rate correctly', function () {
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', false);
$summary = $this->collector->getSummary();
// 3 hits / 4 total = 75%
expect($summary['cache_hit_rate'])->toBe(75.0);
});
it('handles zero cache accesses gracefully', function () {
$this->collector->recordRender('test', 10.0);
$summary = $this->collector->getSummary();
expect($summary['cache_hit_rate'])->toBe(0.0);
});
it('returns zero values for empty collector', function () {
$summary = $this->collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hits'])->toBe(0);
expect($summary['cache_misses'])->toBe(0);
expect($summary['total_events'])->toBe(0);
expect($summary['action_errors'])->toBe(0);
expect($summary['cache_hit_rate'])->toBe(0.0);
});
});
describe('Prometheus Export', function () {
it('exports metrics in Prometheus format', function () {
$this->collector->recordRender('test-component', 45.5, false);
$this->collector->recordAction('test-component', 'increment', 12.3, true);
$prometheus = $this->collector->exportPrometheus();
// Check Prometheus format
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
// Check metric lines exist
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toContain('livecomponent_actions_total');
expect($prometheus)->toContain('component_id="test-component"');
});
it('includes timestamps in Prometheus format', function () {
$this->collector->recordRender('counter', 10.0);
$prometheus = $this->collector->exportPrometheus();
// Should contain timestamp (unix timestamp format)
expect($prometheus)->toMatch('/\d+\.\d+\s+\d+/');
});
it('handles labels with special characters', function () {
// Test with special characters: quote and newline
$this->collector->recordAction('form"test', "action\nname", 10.0);
$prometheus = $this->collector->exportPrometheus();
// Should escape special characters in Prometheus format
// Quote: " becomes \"
expect($prometheus)->toContain('form\\"test');
// Newline: \n becomes \n (literal backslash-n in output)
expect($prometheus)->toContain('action\nname');
});
it('exports empty string for empty collector', function () {
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
});
});
describe('Metric Key Building', function () {
it('creates unique keys for metrics with different labels', function () {
$this->collector->recordRender('component-a', 10.0, true);
$this->collector->recordRender('component-b', 20.0, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-a}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-b}');
});
it('sorts labels alphabetically in metric keys', function () {
// Record action (labels: component_id, action, status)
$this->collector->recordAction('test', 'action1', 10.0, true);
$metrics = $this->collector->getMetrics();
// Key should have labels sorted: action, component_id, status
expect($metrics)->toHaveKey('livecomponent_actions_total{action=action1,component_id=test,status=success}');
});
});
describe('Metric Retrieval', function () {
it('retrieves metric by exact key', function () {
$this->collector->recordRender('my-component', 50.0);
$metric = $this->collector->getMetric('livecomponent_renders_total{cached=false,component_id=my-component}');
expect($metric)->not->toBeNull();
expect($metric->value)->toBe(1.0);
});
it('returns null for non-existent metric', function () {
$metric = $this->collector->getMetric('non_existent_metric');
expect($metric)->toBeNull();
});
it('retrieves all metrics', function () {
$this->collector->recordRender('comp1', 10.0);
$this->collector->recordRender('comp2', 20.0);
$this->collector->recordAction('comp1', 'action1', 30.0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toBeArray();
expect(count($metrics))->toBeGreaterThan(0);
});
});
describe('Reset Functionality', function () {
it('clears all metrics on reset', function () {
// Add various metrics
$this->collector->recordRender('test', 10.0);
$this->collector->recordAction('test', 'action', 20.0);
$this->collector->recordCacheHit('test', true);
expect($this->collector->getMetrics())->not->toBeEmpty();
// Reset
$this->collector->reset();
expect($this->collector->getMetrics())->toBeEmpty();
});
it('allows recording new metrics after reset', function () {
$this->collector->recordRender('test', 10.0);
$this->collector->reset();
$this->collector->recordRender('test', 20.0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveCount(2); // renders_total + render_duration_ms
});
it('resets summary to zero values', function () {
$this->collector->recordRender('test', 10.0);
$this->collector->reset();
$summary = $this->collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hits'])->toBe(0);
});
});
describe('Counter Incrementing', function () {
it('increments counter metric on repeated calls', function () {
$this->collector->recordRender('counter-test', 10.0);
$this->collector->recordRender('counter-test', 15.0);
$this->collector->recordRender('counter-test', 20.0);
$metrics = $this->collector->getMetrics();
$counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=counter-test}'];
expect($counterMetric->value)->toBe(3.0);
});
it('increments by custom amount in batch operations', function () {
// Batch with 5 successes
$this->collector->recordBatch(5, 100.0, 5, 0);
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_batch_success_total']->value)->toBe(5.0);
});
});
describe('Histogram Updates', function () {
it('updates histogram value on repeated observations', function () {
$this->collector->recordRender('histogram-test', 50.0);
$this->collector->recordRender('histogram-test', 100.0);
$metrics = $this->collector->getMetrics();
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=histogram-test}'];
// Latest observation should be stored
expect($histogramMetric->value)->toBe(100.0);
});
});
describe('Metric Metadata', function () {
it('stores metric type correctly', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
$counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=test}'];
expect($counterMetric->type)->toBe(MetricType::COUNTER);
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}'];
expect($histogramMetric->type)->toBe(MetricType::HISTOGRAM);
});
it('stores labels correctly', function () {
$this->collector->recordAction('my-component', 'my-action', 50.0, true);
$metrics = $this->collector->getMetrics();
$actionMetric = $metrics['livecomponent_actions_total{action=my-action,component_id=my-component,status=success}'];
expect($actionMetric->labels)->toHaveKey('component_id');
expect($actionMetric->labels)->toHaveKey('action');
expect($actionMetric->labels)->toHaveKey('status');
expect($actionMetric->labels['component_id'])->toBe('my-component');
expect($actionMetric->labels['action'])->toBe('my-action');
expect($actionMetric->labels['status'])->toBe('success');
});
it('stores unit for duration histograms', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}'];
expect($histogramMetric->unit)->toBe('ms');
});
it('stores timestamp for all metrics', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
foreach ($metrics as $metric) {
expect($metric->timestamp)->not->toBeNull();
}
});
});
});

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Core\ValueObjects\Timestamp;
describe('ComponentMetricsCollector - Prometheus Export Validation', function () {
beforeEach(function () {
$this->collector = new ComponentMetricsCollector();
});
describe('Prometheus Format Validation', function () {
it('exports valid prometheus format with header', function () {
$this->collector->recordRender('test-component', 50.5);
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
});
it('exports metrics with proper line format', function () {
$this->collector->recordRender('test-component', 50.5);
$prometheus = $this->collector->exportPrometheus();
$lines = explode("\n", trim($prometheus));
// Find metric line (skip header comments)
$metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line));
foreach ($metricLines as $line) {
// Format: metric_name{labels} value timestamp
expect($line)->toMatch('/^[a-z_]+\{.*\}\s+[\d.]+\s+\d+$/');
}
});
it('exports counter metrics correctly', function () {
$this->collector->recordRender('counter-test', 10.0);
$prometheus = $this->collector->exportPrometheus();
// Counter should be in format: livecomponent_renders_total{...} 1.00 timestamp
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toMatch('/livecomponent_renders_total\{.*\}\s+1\.00\s+\d+/');
});
it('exports histogram metrics correctly', function () {
$this->collector->recordRender('histogram-test', 45.75);
$prometheus = $this->collector->exportPrometheus();
// Histogram should be in format: livecomponent_render_duration_ms{...} 45.75 timestamp
expect($prometheus)->toContain('livecomponent_render_duration_ms');
expect($prometheus)->toMatch('/livecomponent_render_duration_ms\{.*\}\s+45\.75\s+\d+/');
});
});
describe('Label Formatting', function () {
it('formats labels with alphabetical sorting', function () {
$this->collector->recordAction('component-1', 'submit', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Labels should be sorted: action, component_id, status
expect($prometheus)->toContain('action="submit"');
expect($prometheus)->toContain('component_id="component-1"');
expect($prometheus)->toContain('status="success"');
// Verify alphabetical order in label string
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => str_contains($line, 'livecomponent_actions_total')
);
foreach ($metricLines as $line) {
// Extract label section between {}
preg_match('/\{(.*?)\}/', $line, $matches);
if (isset($matches[1])) {
$labels = explode(',', $matches[1]);
$labelKeys = array_map(fn($label) => explode('=', $label)[0], $labels);
// Verify alphabetical order
$sortedKeys = $labelKeys;
sort($sortedKeys);
expect($labelKeys)->toBe($sortedKeys);
}
}
});
it('escapes special characters in label values', function () {
// Component with special characters: quotes, backslashes, newlines
$this->collector->recordRender('test"component', 50.0);
$this->collector->recordAction('comp\\path', "action\nname", 10.0); // Use double quotes for actual newline
$prometheus = $this->collector->exportPrometheus();
// Quotes should be escaped as \"
expect($prometheus)->toContain('test\\"component');
// Backslashes should be escaped as \\
expect($prometheus)->toContain('comp\\\\path');
// Newlines should be escaped as \n in Prometheus format
// The actual newline character (\n) should appear as backslash-n in the output
expect($prometheus)->toMatch('/action\\\\nname/'); // Regex to find literal \n
});
it('handles empty labels correctly', function () {
// recordHistogram with no labels (internal method tested via batch)
$this->collector->recordBatch(10, 500.0, 8, 2);
$prometheus = $this->collector->exportPrometheus();
// Some metrics should have empty labels {}
expect($prometheus)->toContain('livecomponent_batch_size');
expect($prometheus)->toContain('livecomponent_batch_duration_ms');
// These metrics have labels
expect($prometheus)->toContain('livecomponent_batch_operations_total{status="executed"}');
});
it('formats boolean labels as strings', function () {
$this->collector->recordRender('component-1', 50.0, cached: false);
$this->collector->recordRender('component-2', 25.0, cached: true);
$prometheus = $this->collector->exportPrometheus();
// Boolean should be formatted as "true" or "false" strings
expect($prometheus)->toContain('cached="false"');
expect($prometheus)->toContain('cached="true"');
expect($prometheus)->not->toContain('cached=true');
expect($prometheus)->not->toContain('cached=false');
});
});
describe('Value Formatting', function () {
it('formats float values with 2 decimal places', function () {
$this->collector->recordRender('test', 123.456);
$prometheus = $this->collector->exportPrometheus();
// Values should have .2f format
expect($prometheus)->toContain('123.46'); // Rounded to 2 decimals
});
it('formats integer values with .00', function () {
$this->collector->recordAction('test', 'click', 100.0); // Creates counter = 1
$prometheus = $this->collector->exportPrometheus();
// Counter value should be 1.00
expect($prometheus)->toMatch('/livecomponent_actions_total\{.*\}\s+1\.00/');
});
it('handles zero values correctly', function () {
// Create metric and then subtract to get 0 (through reset and re-create)
$this->collector->recordRender('test', 0.0);
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('0.00');
});
});
describe('Timestamp Formatting', function () {
it('includes unix timestamp for each metric', function () {
$beforeTime = time();
$this->collector->recordRender('test', 50.0);
$afterTime = time();
$prometheus = $this->collector->exportPrometheus();
// Extract timestamp from metric line
preg_match('/livecomponent_renders_total\{.*\}\s+[\d.]+\s+(\d+)/', $prometheus, $matches);
expect($matches)->toHaveKey(1);
$timestamp = (int) $matches[1];
// Timestamp should be within test execution time range
expect($timestamp)->toBeGreaterThanOrEqual($beforeTime);
expect($timestamp)->toBeLessThanOrEqual($afterTime + 1); // +1 for race condition
});
it('uses metric timestamp if available', function () {
$this->collector->recordRender('test', 50.0);
$prometheus = $this->collector->exportPrometheus();
// Timestamp should be present in all metric lines
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
foreach ($metricLines as $line) {
expect($line)->toMatch('/\d+$/'); // Line ends with timestamp
}
});
});
describe('Multiple Metrics Export', function () {
it('exports multiple metrics with different labels', function () {
$this->collector->recordRender('component-1', 50.0, cached: false);
$this->collector->recordRender('component-2', 30.0, cached: true);
$this->collector->recordAction('component-1', 'submit', 100.0);
$this->collector->recordAction('component-2', 'click', 75.0);
$prometheus = $this->collector->exportPrometheus();
// Should have multiple metric lines
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
expect(count($metricLines))->toBeGreaterThan(4); // At least 2 renders + 2 actions (each creates 2 metrics)
});
it('exports metrics in consistent format', function () {
$this->collector->recordRender('comp-1', 10.0);
$this->collector->recordAction('comp-2', 'test', 20.0);
$this->collector->recordCacheHit('comp-3', true);
$this->collector->recordEventDispatched('comp-4', 'updated');
$prometheus = $this->collector->exportPrometheus();
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
// All lines should match Prometheus format
foreach ($metricLines as $line) {
// Format: metric_name{labels} value timestamp
expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/');
}
});
});
describe('Special Cases', function () {
it('handles empty metrics collection', function () {
$prometheus = $this->collector->exportPrometheus();
// Should still have headers even with no metrics
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
// Should only have headers (2 lines + empty line)
$lines = explode("\n", $prometheus);
$metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line));
expect(count($metricLines))->toBe(0);
});
it('handles metrics with complex label combinations', function () {
$this->collector->recordAction('cart-component', 'add-to-cart', 150.0, success: true);
$this->collector->recordAction('cart-component', 'add-to-cart', 200.0, success: false);
$this->collector->recordAction('checkout-component', 'submit-payment', 300.0, success: true);
$prometheus = $this->collector->exportPrometheus();
// Should have multiple metrics with different label combinations
expect($prometheus)->toContain('component_id="cart-component"');
expect($prometheus)->toContain('action="add-to-cart"');
expect($prometheus)->toContain('status="success"');
expect($prometheus)->toContain('status="error"');
expect($prometheus)->toContain('component_id="checkout-component"');
expect($prometheus)->toContain('action="submit-payment"');
});
it('handles incremental counter updates', function () {
// Record same action 3 times
$this->collector->recordAction('test', 'click', 10.0);
$this->collector->recordAction('test', 'click', 15.0);
$this->collector->recordAction('test', 'click', 20.0);
$prometheus = $this->collector->exportPrometheus();
// Counter should be incremented to 3
preg_match('/livecomponent_actions_total\{.*\}\s+([\d.]+)/', $prometheus, $matches);
expect($matches)->toHaveKey(1);
expect((float) $matches[1])->toBe(3.0);
});
});
describe('Integration with Metric Types', function () {
it('exports all LiveComponent metric types', function () {
// Render metrics
$this->collector->recordRender('comp', 50.0);
// Action metrics
$this->collector->recordAction('comp', 'submit', 100.0);
// Cache metrics
$this->collector->recordCacheHit('comp', true);
// Event metrics
$this->collector->recordEventDispatched('comp', 'updated');
$this->collector->recordEventReceived('comp', 'refresh');
// Hydration metrics
$this->collector->recordHydration('comp', 30.0);
// Batch metrics
$this->collector->recordBatch(10, 500.0, 8, 2);
// Fragment metrics
$this->collector->recordFragmentUpdate('comp', 5, 75.0);
// Upload metrics
$this->collector->recordUploadChunk('session-1', 1, 200.0);
$this->collector->recordUploadComplete('session-1', 2000.0, 10);
$prometheus = $this->collector->exportPrometheus();
// Verify all metric types are present
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toContain('livecomponent_render_duration_ms');
expect($prometheus)->toContain('livecomponent_actions_total');
expect($prometheus)->toContain('livecomponent_action_duration_ms');
expect($prometheus)->toContain('livecomponent_cache_hits_total');
expect($prometheus)->toContain('livecomponent_events_dispatched_total');
expect($prometheus)->toContain('livecomponent_events_received_total');
expect($prometheus)->toContain('livecomponent_hydration_duration_ms');
expect($prometheus)->toContain('livecomponent_batch_operations_total');
expect($prometheus)->toContain('livecomponent_fragment_updates_total');
expect($prometheus)->toContain('livecomponent_upload_chunks_total');
expect($prometheus)->toContain('livecomponent_uploads_completed_total');
});
});
describe('Prometheus Format Compliance', function () {
it('follows Prometheus metric naming conventions', function () {
$this->collector->recordRender('test', 50.0);
$this->collector->recordAction('test', 'click', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Metric names should be lowercase with underscores
// Should end with _total for counters or _ms for duration histograms
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
foreach ($metricLines as $line) {
preg_match('/^([a-z_]+)/', $line, $matches);
$metricName = $matches[1] ?? '';
expect($metricName)->toMatch('/^[a-z][a-z0-9_]*$/');
// Counters should end with _total
if (str_contains($line, 'renders_total') || str_contains($line, 'actions_total')) {
expect($metricName)->toMatch('/_total$/');
}
// Duration metrics should end with _ms
if (str_contains($line, 'duration')) {
expect($metricName)->toMatch('/_ms$/');
}
}
});
it('uses valid label key naming', function () {
$this->collector->recordAction('test', 'submit', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Extract label keys
preg_match_all('/([a-z_]+)="[^"]*"/', $prometheus, $matches);
$labelKeys = $matches[1] ?? [];
// Label keys should be lowercase with underscores
foreach ($labelKeys as $key) {
expect($key)->toMatch('/^[a-z][a-z0-9_]*$/');
}
});
it('produces parseable prometheus output', function () {
$this->collector->recordRender('test', 50.0);
$this->collector->recordAction('test', 'click', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Basic structure validation
$lines = explode("\n", $prometheus);
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
// Either comment or metric line
if (str_starts_with($line, '#')) {
// Comment line - should start with # HELP or # TYPE
expect($line)->toMatch('/^#\s+(HELP|TYPE)\s+/');
} else {
// Metric line - should have metric name, optional labels, value, timestamp
expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/');
}
}
});
});
});

View File

@@ -0,0 +1,712 @@
/**
* @jest-environment jsdom
*/
import { LiveComponentDevTools } from '../../../../../resources/js/modules/LiveComponentDevTools.js';
import { Core } from '../../../../../resources/js/modules/core.js';
describe('LiveComponentDevTools', () => {
let devTools;
let mockLocalStorage;
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Mock localStorage
mockLocalStorage = {};
global.localStorage = {
getItem: jest.fn((key) => mockLocalStorage[key] || null),
setItem: jest.fn((key, value) => { mockLocalStorage[key] = value; }),
removeItem: jest.fn((key) => { delete mockLocalStorage[key]; }),
clear: jest.fn(() => { mockLocalStorage = {}; })
};
// Set development environment
document.documentElement.dataset.env = 'development';
// Mock Core.emit
Core.emit = jest.fn();
Core.on = jest.fn();
// Mock performance.memory (Chrome-specific)
if (!window.performance.memory) {
window.performance.memory = {
usedJSHeapSize: 10000000,
totalJSHeapSize: 20000000,
jsHeapSizeLimit: 100000000
};
}
// Mock window.LiveComponent
window.LiveComponent = {
enableDevTools: jest.fn()
};
});
afterEach(() => {
// Cleanup
if (devTools && devTools.overlay) {
devTools.overlay.remove();
}
devTools = null;
});
describe('Initialization', () => {
it('initializes in development mode', () => {
document.documentElement.dataset.env = 'development';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(true);
expect(devTools.overlay).not.toBeNull();
expect(document.getElementById('livecomponent-devtools')).not.toBeNull();
});
it('does not initialize in production mode by default', () => {
document.documentElement.dataset.env = 'production';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(false);
expect(devTools.overlay).toBeNull();
});
it('initializes in production with explicit localStorage flag', () => {
document.documentElement.dataset.env = 'production';
mockLocalStorage['livecomponent_devtools'] = 'true';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(true);
expect(devTools.overlay).not.toBeNull();
});
it('creates all required UI elements', () => {
devTools = new LiveComponentDevTools();
const overlay = document.getElementById('livecomponent-devtools');
expect(overlay).not.toBeNull();
// Check header
expect(overlay.querySelector('.lc-devtools__header')).not.toBeNull();
expect(overlay.querySelector('.lc-devtools__title')).not.toBeNull();
// Check tabs
expect(overlay.querySelector('[data-tab="components"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="actions"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="events"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="performance"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="network"]')).not.toBeNull();
// Check panes
expect(overlay.querySelector('[data-pane="components"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="actions"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="events"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="performance"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="network"]')).not.toBeNull();
// Check action buttons
expect(overlay.querySelector('[data-action="minimize"]')).not.toBeNull();
expect(overlay.querySelector('[data-action="close"]')).not.toBeNull();
expect(overlay.querySelector('[data-action="toggle-badges"]')).not.toBeNull();
});
it('connects to LiveComponent manager', (done) => {
devTools = new LiveComponentDevTools();
setTimeout(() => {
expect(window.LiveComponent.enableDevTools).toHaveBeenCalledWith(devTools);
done();
}, 150);
});
});
describe('Component Discovery', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('discovers components via data-component-id attribute', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter">
<span>Count: 0</span>
</div>
<div data-component-id="user-stats:123" data-component-name="UserStats">
<p>Stats</p>
</div>
`;
devTools.refreshComponents();
expect(devTools.components.size).toBe(2);
expect(devTools.components.has('counter:demo')).toBe(true);
expect(devTools.components.has('user-stats:123')).toBe(true);
const counter = devTools.components.get('counter:demo');
expect(counter.name).toBe('Counter');
expect(counter.id).toBe('counter:demo');
});
it('extracts component props from data attributes', () => {
document.body.innerHTML = `
<div
data-component-id="product:5"
data-component-name="Product"
data-product-id="5"
data-category="electronics"
>
Product
</div>
`;
devTools.refreshComponents();
const product = devTools.components.get('product:5');
expect(product.props.productId).toBe('5');
expect(product.props.category).toBe('electronics');
expect(product.props.componentId).toBeUndefined(); // Excluded
expect(product.props.componentName).toBeUndefined(); // Excluded
});
it('handles components without data-component-name', () => {
document.body.innerHTML = `
<div data-component-id="anonymous:1">Content</div>
`;
devTools.refreshComponents();
const component = devTools.components.get('anonymous:1');
expect(component.name).toBe('Unknown');
});
});
describe('Action Logging', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('logs action executions', () => {
const startTime = 100;
const endTime = 150;
devTools.logAction('counter:demo', 'increment', { amount: 5 }, startTime, endTime, true);
expect(devTools.actionLog.length).toBe(1);
const logEntry = devTools.actionLog[0];
expect(logEntry.componentId).toBe('counter:demo');
expect(logEntry.actionName).toBe('increment');
expect(logEntry.params).toEqual({ amount: 5 });
expect(logEntry.duration).toBe(50);
expect(logEntry.success).toBe(true);
expect(logEntry.error).toBeNull();
});
it('logs action failures with error messages', () => {
devTools.logAction('form:contact', 'submit', {}, 100, 200, false, 'Validation failed');
const logEntry = devTools.actionLog[0];
expect(logEntry.success).toBe(false);
expect(logEntry.error).toBe('Validation failed');
});
it('maintains max 100 action log entries', () => {
// Add 110 actions
for (let i = 0; i < 110; i++) {
devTools.logAction(`component:${i}`, 'action', {}, 0, 10, true);
}
expect(devTools.actionLog.length).toBe(100);
// Most recent should be at index 0
expect(devTools.actionLog[0].componentId).toBe('component:109');
});
it('records action execution for performance profiling when recording', () => {
devTools.isRecording = true;
devTools.logAction('counter:demo', 'increment', {}, 100, 150, true);
expect(devTools.performanceRecording.length).toBe(1);
expect(devTools.performanceRecording[0].type).toBe('action');
expect(devTools.performanceRecording[0].duration).toBe(50);
});
it('does not record performance when not recording', () => {
devTools.isRecording = false;
devTools.logAction('counter:demo', 'increment', {}, 100, 150, true);
expect(devTools.performanceRecording.length).toBe(0);
});
});
describe('Event Logging', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('logs events with source tracking', () => {
devTools.logEvent('user:updated', { userId: 123 }, 'client');
expect(devTools.eventLog.length).toBe(1);
const logEntry = devTools.eventLog[0];
expect(logEntry.eventName).toBe('user:updated');
expect(logEntry.data).toEqual({ userId: 123 });
expect(logEntry.source).toBe('client');
});
it('defaults source to client', () => {
devTools.logEvent('notification:received', { message: 'Hello' });
expect(devTools.eventLog[0].source).toBe('client');
});
it('maintains max 100 event log entries', () => {
// Add 110 events
for (let i = 0; i < 110; i++) {
devTools.logEvent(`event:${i}`, {}, 'client');
}
expect(devTools.eventLog.length).toBe(100);
// Most recent should be at index 0
expect(devTools.eventLog[0].eventName).toBe('event:109');
});
it('intercepts Core.emit for automatic event logging', () => {
const originalEmit = Core.emit;
devTools.interceptCoreEmit();
Core.emit('test:event', { data: 'test' });
expect(devTools.eventLog.length).toBe(1);
expect(devTools.eventLog[0].eventName).toBe('test:event');
expect(devTools.eventLog[0].data).toEqual({ data: 'test' });
expect(devTools.eventLog[0].source).toBe('client');
});
});
describe('Network Monitoring', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
// Mock original fetch
global.originalFetch = global.fetch;
global.fetch = jest.fn();
});
afterEach(() => {
// Restore original fetch
global.fetch = global.originalFetch;
});
it('intercepts fetch requests', async () => {
global.fetch.mockResolvedValue({
status: 200,
ok: true
});
devTools.monitorNetworkRequests();
await window.fetch('/api/users');
expect(devTools.networkLog.length).toBe(1);
const logEntry = devTools.networkLog[0];
expect(logEntry.method).toBe('GET');
expect(logEntry.url).toBe('/api/users');
expect(logEntry.status).toBe(200);
expect(logEntry.duration).toBeGreaterThanOrEqual(0);
});
it('logs POST requests with correct method', async () => {
global.fetch.mockResolvedValue({
status: 201,
ok: true
});
devTools.monitorNetworkRequests();
await window.fetch('/api/users', { method: 'POST' });
expect(devTools.networkLog[0].method).toBe('POST');
expect(devTools.networkLog[0].status).toBe(201);
});
it('maintains max 50 network log entries', async () => {
global.fetch.mockResolvedValue({ status: 200, ok: true });
devTools.monitorNetworkRequests();
// Make 60 requests
for (let i = 0; i < 60; i++) {
await window.fetch(`/api/request/${i}`);
}
expect(devTools.networkLog.length).toBe(50);
});
it('logs failed requests', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
devTools.monitorNetworkRequests();
try {
await window.fetch('/api/fail');
} catch (error) {
// Expected
}
expect(devTools.networkLog.length).toBe(1);
expect(devTools.networkLog[0].status).toBe('Error');
});
});
describe('Tab Switching', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
devTools.open();
});
it('switches to actions tab', () => {
devTools.switchTab('actions');
expect(devTools.activeTab).toBe('actions');
const actionsTab = devTools.overlay.querySelector('[data-tab="actions"]');
const actionsPane = devTools.overlay.querySelector('[data-pane="actions"]');
expect(actionsTab.classList.contains('lc-devtools__tab--active')).toBe(true);
expect(actionsPane.classList.contains('lc-devtools__pane--active')).toBe(true);
});
it('deactivates previous tab when switching', () => {
devTools.switchTab('events');
const componentsTab = devTools.overlay.querySelector('[data-tab="components"]');
const componentsPane = devTools.overlay.querySelector('[data-pane="components"]');
expect(componentsTab.classList.contains('lc-devtools__tab--active')).toBe(false);
expect(componentsPane.classList.contains('lc-devtools__pane--active')).toBe(false);
});
it('refreshes content when switching tabs', () => {
devTools.renderActionLog = jest.fn();
devTools.switchTab('actions');
expect(devTools.renderActionLog).toHaveBeenCalled();
});
});
describe('Open/Close/Minimize', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('opens DevTools and emits event', () => {
devTools.open();
expect(devTools.isOpen).toBe(true);
expect(devTools.overlay.style.display).toBe('block');
expect(Core.emit).toHaveBeenCalledWith('devtools:opened');
});
it('closes DevTools and emits event', () => {
devTools.open();
devTools.close();
expect(devTools.isOpen).toBe(false);
expect(devTools.overlay.style.display).toBe('none');
expect(Core.emit).toHaveBeenCalledWith('devtools:closed');
});
it('toggles DevTools visibility', () => {
expect(devTools.isOpen).toBe(false);
devTools.toggle();
expect(devTools.isOpen).toBe(true);
devTools.toggle();
expect(devTools.isOpen).toBe(false);
});
it('minimizes and unminimizes DevTools', () => {
devTools.open();
devTools.toggleMinimize();
expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(true);
devTools.toggleMinimize();
expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(false);
});
});
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('toggles DevTools with Ctrl+Shift+D', () => {
const event = new KeyboardEvent('keydown', {
key: 'D',
ctrlKey: true,
shiftKey: true
});
document.dispatchEvent(event);
expect(devTools.isOpen).toBe(true);
});
it('toggles DevTools with Cmd+Shift+D on Mac', () => {
const event = new KeyboardEvent('keydown', {
key: 'D',
metaKey: true, // Cmd key on Mac
shiftKey: true
});
document.dispatchEvent(event);
expect(devTools.isOpen).toBe(true);
});
});
describe('DOM Badges', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('creates DOM badges for components', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter">
Counter
</div>
`;
devTools.updateDomBadges();
expect(devTools.domBadges.size).toBe(1);
expect(devTools.domBadges.has('counter:demo')).toBe(true);
const badge = document.querySelector('.lc-dom-badge[data-component-id="counter:demo"]');
expect(badge).not.toBeNull();
expect(badge.textContent).toContain('Counter');
});
it('updates badge activity counter', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
devTools.updateBadgeActivity('counter:demo');
const badgeData = devTools.domBadges.get('counter:demo');
expect(badgeData.actionCount).toBe(1);
const actionsSpan = badgeData.badge.querySelector('.lc-badge-actions');
expect(actionsSpan.textContent).toBe('1 action');
});
it('pluralizes action count correctly', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
devTools.updateBadgeActivity('counter:demo');
devTools.updateBadgeActivity('counter:demo');
const actionsSpan = devTools.domBadges.get('counter:demo').badge.querySelector('.lc-badge-actions');
expect(actionsSpan.textContent).toBe('2 actions');
});
it('toggles badge visibility', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
const badgeData = devTools.domBadges.get('counter:demo');
expect(badgeData.badge.style.display).not.toBe('none');
devTools.toggleBadges();
expect(badgeData.badge.style.display).toBe('none');
devTools.toggleBadges();
expect(badgeData.badge.style.display).toBe('block');
});
it('cleans up badges for removed components', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
expect(devTools.domBadges.size).toBe(1);
// Remove component from DOM
document.body.innerHTML = '';
devTools.cleanupRemovedBadges();
expect(devTools.domBadges.size).toBe(0);
});
});
describe('Performance Profiling', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('starts performance recording', () => {
devTools.startPerformanceRecording();
expect(devTools.isRecording).toBe(true);
expect(devTools.performanceRecording).toEqual([]);
expect(devTools.memorySnapshots.length).toBeGreaterThan(0); // Initial snapshot
});
it('stops performance recording', () => {
devTools.startPerformanceRecording();
devTools.stopPerformanceRecording();
expect(devTools.isRecording).toBe(false);
});
it('records component renders', () => {
devTools.isRecording = true;
devTools.recordComponentRender('counter:demo', 25, 100, 125);
expect(devTools.performanceRecording.length).toBe(1);
expect(devTools.performanceRecording[0].type).toBe('render');
expect(devTools.performanceRecording[0].componentId).toBe('counter:demo');
expect(devTools.performanceRecording[0].duration).toBe(25);
});
it('does not record when not recording', () => {
devTools.isRecording = false;
devTools.recordComponentRender('counter:demo', 25, 100, 125);
expect(devTools.performanceRecording.length).toBe(0);
});
it('takes memory snapshots', () => {
devTools.takeMemorySnapshot();
expect(devTools.memorySnapshots.length).toBe(1);
const snapshot = devTools.memorySnapshots[0];
expect(snapshot.usedJSHeapSize).toBeDefined();
expect(snapshot.totalJSHeapSize).toBeDefined();
expect(snapshot.jsHeapSizeLimit).toBeDefined();
expect(snapshot.timestamp).toBeDefined();
});
it('maintains max 100 performance recordings', () => {
devTools.isRecording = true;
for (let i = 0; i < 110; i++) {
devTools.recordComponentRender(`component:${i}`, 10, 0, 10);
}
expect(devTools.performanceRecording.length).toBe(100);
});
it('maintains max 100 memory snapshots', () => {
for (let i = 0; i < 110; i++) {
devTools.takeMemorySnapshot();
}
expect(devTools.memorySnapshots.length).toBe(100);
});
});
describe('Clear Operations', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('clears action log', () => {
devTools.logAction('component:1', 'action', {}, 0, 10, true);
devTools.logAction('component:2', 'action', {}, 0, 10, true);
expect(devTools.actionLog.length).toBe(2);
devTools.clearActionLog();
expect(devTools.actionLog.length).toBe(0);
});
it('clears event log', () => {
devTools.logEvent('event:1', {});
devTools.logEvent('event:2', {});
expect(devTools.eventLog.length).toBe(2);
devTools.clearEventLog();
expect(devTools.eventLog.length).toBe(0);
});
it('clears network log', () => {
devTools.networkLog = [
{ timestamp: Date.now(), method: 'GET', url: '/api/users', status: 200, duration: 50 },
{ timestamp: Date.now(), method: 'POST', url: '/api/posts', status: 201, duration: 100 }
];
expect(devTools.networkLog.length).toBe(2);
devTools.clearNetworkLog();
expect(devTools.networkLog.length).toBe(0);
});
it('clears performance data', () => {
devTools.performanceRecording = [{ type: 'action', duration: 10 }];
devTools.componentRenderTimes.set('component:1', [10, 20]);
devTools.actionExecutionTimes.set('component:1:action', [5, 10]);
devTools.memorySnapshots = [{ timestamp: Date.now() }];
devTools.clearPerformanceData();
expect(devTools.performanceRecording.length).toBe(0);
expect(devTools.componentRenderTimes.size).toBe(0);
expect(devTools.actionExecutionTimes.size).toBe(0);
expect(devTools.memorySnapshots.length).toBe(0);
});
});
describe('Utility Methods', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('formats bytes to human-readable string', () => {
expect(devTools.formatBytes(0)).toBe('0 B');
expect(devTools.formatBytes(1024)).toBe('1 KB');
expect(devTools.formatBytes(1048576)).toBe('1 MB');
expect(devTools.formatBytes(1073741824)).toBe('1 GB');
expect(devTools.formatBytes(1536)).toBe('1.5 KB');
});
it('checks if enabled in development mode', () => {
document.documentElement.dataset.env = 'development';
expect(devTools.checkIfEnabled()).toBe(true);
});
it('checks if enabled with localStorage override', () => {
document.documentElement.dataset.env = 'production';
mockLocalStorage['livecomponent_devtools'] = 'true';
expect(devTools.checkIfEnabled()).toBe(true);
});
it('is disabled in production without override', () => {
document.documentElement.dataset.env = 'production';
expect(devTools.checkIfEnabled()).toBe(false);
});
});
});