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,650 @@
# LiveComponents Chunked Upload E2E Tests
Comprehensive end-to-end testing suite for the LiveComponents chunked upload system.
## Overview
This test suite validates the complete chunked upload functionality including:
- Upload session initialization and management
- Chunk splitting and parallel uploads
- SHA-256 integrity verification
- Progress tracking via SSE
- Resume capability for interrupted uploads
- Retry logic with exponential backoff
- Quarantine system integration
- Component state synchronization
## Quick Start
### Prerequisites
```bash
# Ensure Playwright is installed
npm install
# Install browser binaries
npx playwright install chromium
# Ensure development server is running
make up
```
### Running Tests
```bash
# Run all chunked upload tests
npm run test:upload
# Run with visible browser (for debugging)
npm run test:upload:headed
# Run with debug mode
npm run test:upload:debug
# Run specific test
npx playwright test chunked-upload.spec.js --grep "should upload chunks in parallel"
```
## Test Scenarios
### 1. Upload Session Initialization
**Tests:** Session creation with UUID format validation
**Validates:**
- Unique session ID generation
- UUID format (RFC 4122)
- Session metadata storage
- Client-server session sync
**Expected Behavior:**
- Session ID: `^[a-f0-9-]{36}$` (UUID v4 format)
- Session created within 500ms
- Session accessible via `window.__uploadSession`
### 2. Chunk Splitting Verification
**Tests:** File split into correct number of chunks based on size
**Validates:**
- Chunk size calculation (default: 512KB)
- Total chunks = ceil(fileSize / chunkSize)
- Chunk boundaries alignment
**Example:**
- 2MB file → 4 chunks (2048KB / 512KB = 4)
- 1MB file → 2 chunks (1024KB / 512KB = 2)
- 600KB file → 2 chunks (ceil(600KB / 512KB) = 2)
### 3. Parallel Chunk Uploads
**Tests:** Chunks upload concurrently (default: 3 parallel)
**Validates:**
- Multiple chunks upload simultaneously
- Concurrent request detection (time window analysis)
- Upload parallelization efficiency
**Metrics:**
- Expected concurrency: 3 simultaneous uploads
- Time window: Requests within 100ms considered parallel
### 4. Progress Tracking Accuracy
**Tests:** Real-time progress updates from 0% to 100%
**Validates:**
- Monotonic progress increase
- Accurate percentage calculation
- No progress regression
- Final state: 100% completion
**Progress Formula:**
```javascript
progress = (uploadedChunks / totalChunks) * 100
```
### 5. SHA-256 Integrity Verification
**Tests:** Hash calculation and verification for each chunk
**Validates:**
- Client-side hash generation (SHA-256)
- Server-side hash verification
- Hash format: 64 hexadecimal characters
- Upload rejection on hash mismatch
**Hash Calculation:**
```javascript
const hash = await crypto.subtle.digest('SHA-256', chunkData);
const hashHex = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
```
### 6. Upload Interruption and Resume
**Tests:** Resume capability after page reload or connection loss
**Validates:**
- Uploaded chunks persistence
- Resume from last successful chunk
- Skip already uploaded chunks
- Session restoration
**Resume Workflow:**
1. Upload starts, some chunks complete
2. Interruption occurs (reload/disconnect)
3. Session restored with uploaded chunks info
4. Upload resumes from next chunk
5. Completion without re-uploading existing chunks
### 7. Retry Logic with Exponential Backoff
**Tests:** Failed chunks retry with increasing delays
**Validates:**
- Automatic retry on failure
- Exponential backoff strategy
- Maximum retry attempts (default: 3)
- Success after retries
**Backoff Strategy:**
```javascript
delay = baseDelay * Math.pow(2, attemptNumber)
// Attempt 1: 100ms
// Attempt 2: 200ms
// Attempt 3: 400ms
```
### 8. Concurrent Multi-File Uploads
**Tests:** Multiple files upload simultaneously with queue management
**Validates:**
- Independent upload sessions per file
- Progress tracking per file
- Queue management (max concurrent files)
- No session conflicts
**Expected Behavior:**
- Each file: unique session ID
- Independent progress tracking
- Proper resource cleanup
### 9. Real-time SSE Progress Updates
**Tests:** Server-Sent Events for live progress updates
**Validates:**
- SSE connection establishment
- Progress events received
- Event format and data accuracy
- Connection persistence
**SSE Event Format:**
```javascript
{
type: 'upload-progress',
sessionId: 'uuid',
progress: 0-100,
uploadedChunks: number,
totalChunks: number,
bytesUploaded: number,
totalBytes: number
}
```
### 10. Upload Cancellation
**Tests:** User-initiated upload cancellation
**Validates:**
- Cancel button functionality
- Pending chunk requests abortion
- Session cleanup
- UI state reset
**Cancellation Steps:**
1. Upload in progress
2. Cancel button clicked
3. All pending requests aborted
4. Session marked as cancelled
5. Temporary chunks deleted
### 11. File Size Validation
**Tests:** File size limits enforcement
**Validates:**
- Maximum file size check (configurable)
- Client-side pre-validation
- Server-side validation
- Clear error messaging
**Default Limits:**
- Max file size: 100MB (configurable)
- Validation occurs before session creation
### 12. File Type Validation
**Tests:** Allowed file types enforcement
**Validates:**
- MIME type checking
- File extension validation
- Blocked file types rejection
- Whitelist/blacklist support
**Example Allowed Types:**
```javascript
allowedTypes: [
'image/jpeg', 'image/png', 'image/gif',
'application/pdf', 'application/zip'
]
```
### 13. Uploaded File Display
**Tests:** File appears in component after successful upload
**Validates:**
- Fragment update with file list
- File metadata display (name, size, date)
- DOM update verification
- Component state consistency
**Expected DOM:**
```html
<div data-lc-fragment="file-list">
<div class="file-item" data-file-id="uuid">
<span class="file-name">example.pdf</span>
<span class="file-size">2.4 MB</span>
<span class="file-date">2025-01-19</span>
</div>
</div>
```
### 14. Quarantine System Integration
**Tests:** Uploaded files move to quarantine for virus scanning
**Validates:**
- File moved to quarantine directory
- Virus scan initiated (ClamAV/VirusTotal)
- Quarantine status tracking
- Clean/infected file handling
**Quarantine Workflow:**
1. Upload completes successfully
2. File moved to quarantine directory
3. Virus scan job queued
4. Component shows "Scanning..." status
5. Result: Clean → Available, Infected → Deleted
### 15. Component State Updates
**Tests:** Component state synchronizes after upload
**Validates:**
- State updates via LiveComponents protocol
- Fragment rendering with new data
- State persistence across interactions
- No state corruption
### 16. Network Interruption Handling
**Tests:** Graceful handling of network failures
**Validates:**
- Connection loss detection
- Automatic reconnection attempts
- Resume after reconnection
- User notification of connection issues
**Failure Scenarios:**
- Temporary network loss (WiFi disconnect)
- Server unavailability (503 responses)
- Timeout errors (slow connections)
### 17. Performance Tests
**Tests:** Upload performance benchmarks
**Validates:**
- 10MB file uploads in <30 seconds
- Memory efficiency for large files (50MB)
- No memory leaks during uploads
- CPU usage within acceptable limits
**Performance Targets:**
- **Throughput**: >2MB/s on localhost
- **Memory**: <100MB for 50MB file upload
- **CPU**: <50% average during upload
## Test Page Requirements
Tests assume the following test page structure at `https://localhost/livecomponents/test/upload`:
### Required HTML Elements
```html
<div data-component-id="upload:test">
<!-- File Input -->
<input type="file" id="file-input" multiple />
<!-- Upload Control Buttons -->
<button id="upload-btn">Upload</button>
<button id="resume-upload-btn" style="display:none">Resume Upload</button>
<button id="cancel-upload-btn" style="display:none">Cancel Upload</button>
<!-- Progress Display -->
<div id="upload-progress">
<div id="progress-bar">
<div id="progress-fill" style="width: 0%"></div>
</div>
<span id="progress-text">0%</span>
<span id="upload-speed">0 MB/s</span>
</div>
<!-- Status Display -->
<div id="upload-status">Ready</div>
<!-- File List (Fragment) -->
<div data-lc-fragment="file-list">
<!-- Uploaded files will be rendered here -->
</div>
<!-- Quarantine Status -->
<div data-lc-fragment="quarantine-status">
<!-- Virus scan status will be rendered here -->
</div>
</div>
```
### Required Component Actions
```php
final readonly class UploadTestComponent extends LiveComponent
{
#[Action]
public function initializeUpload(string $filename, int $fileSize): array
{
// Return session ID and chunk configuration
return [
'sessionId' => Uuid::generate(),
'chunkSize' => 512 * 1024, // 512KB
'totalChunks' => ceil($fileSize / (512 * 1024))
];
}
#[Action]
public function uploadChunk(
string $sessionId,
int $chunkIndex,
string $chunkData,
string $hash
): array {
// Verify hash, store chunk, return progress
return [
'success' => true,
'uploadedChunks' => $chunkIndex + 1,
'progress' => (($chunkIndex + 1) / $totalChunks) * 100
];
}
#[Action]
public function completeUpload(string $sessionId): array
{
// Assemble file, move to quarantine, trigger scan
return [
'success' => true,
'fileId' => $fileId,
'quarantineStatus' => 'scanning'
];
}
#[Action]
public function cancelUpload(string $sessionId): array
{
// Clean up session and temporary chunks
return ['success' => true];
}
}
```
### SSE Endpoint
```php
#[Route(path: '/upload-progress/{sessionId}', method: Method::GET)]
public function uploadProgress(string $sessionId): SseStream
{
return new SseStream(function() use ($sessionId) {
while ($session = $this->getUploadSession($sessionId)) {
yield [
'type' => 'upload-progress',
'sessionId' => $sessionId,
'progress' => $session->getProgress(),
'uploadedChunks' => $session->getUploadedChunks(),
'totalChunks' => $session->getTotalChunks()
];
sleep(1);
if ($session->isComplete()) {
break;
}
}
});
}
```
## Configuration
### Environment Variables
```env
# Chunked Upload Configuration
UPLOAD_CHUNK_SIZE=524288 # 512KB in bytes
UPLOAD_MAX_FILE_SIZE=104857600 # 100MB in bytes
UPLOAD_PARALLEL_CHUNKS=3 # Concurrent uploads
UPLOAD_RETRY_ATTEMPTS=3 # Max retries per chunk
UPLOAD_RETRY_BASE_DELAY=100 # Base delay in ms
UPLOAD_QUARANTINE_PATH=/var/quarantine
UPLOAD_VIRUS_SCAN_ENABLED=true
```
### Test Configuration
```javascript
// In chunked-upload.spec.js
const TEST_CONFIG = {
baseUrl: 'https://localhost',
testPagePath: '/livecomponents/test/upload',
testFilesDir: './tests/tmp/upload-test-files',
timeouts: {
uploadSmall: 10000, // 10s for small files
uploadMedium: 30000, // 30s for medium files
uploadLarge: 120000 // 120s for large files
}
};
```
## Troubleshooting
### Tests Timing Out
**Symptoms:**
- Upload tests exceed timeout limits
- Progress stuck at certain percentage
**Solutions:**
```javascript
// Increase timeout for specific test
test('large file upload', async ({ page }) => {
test.setTimeout(120000); // 120 seconds for large files
// ...
});
```
### File Generation Issues
**Symptoms:**
- Test files not created
- Permission errors in tmp directory
**Solutions:**
```bash
# Ensure tmp directory exists and is writable
mkdir -p tests/tmp/upload-test-files
chmod 755 tests/tmp/upload-test-files
# Clean up before running tests
rm -rf tests/tmp/upload-test-files/*
```
### Network Request Monitoring Failures
**Symptoms:**
- Parallel upload detection fails
- Request interception not working
**Solutions:**
```javascript
// Set up request monitoring before navigation
await page.route('**/live-component/**', route => {
// Your intercept logic
route.continue();
});
await page.goto('https://localhost/livecomponents/test/upload');
```
### SSE Connection Issues
**Symptoms:**
- Progress updates not received
- SSE event listener not triggering
**Solutions:**
```javascript
// Verify SSE connection
await page.evaluate(() => {
const eventSource = new EventSource('/upload-progress/session-id');
eventSource.onmessage = (event) => {
console.log('SSE Event:', event.data);
};
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
};
});
```
### Memory Issues with Large Files
**Symptoms:**
- Browser crashes during large file uploads
- Out of memory errors
**Solutions:**
```javascript
// Use smaller test files or increase browser memory
const browser = await chromium.launch({
args: ['--max-old-space-size=4096'] // 4GB memory limit
});
// Or reduce test file sizes
const testFile = await createTestFile('test-medium.bin', 10); // 10MB instead of 50MB
```
## Continuous Integration
### GitHub Actions Integration
```yaml
name: Chunked Upload E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
upload-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start dev server
run: make up
- name: Run chunked upload tests
run: npm run test:upload
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: upload-test-results
path: test-results/
```
## Best Practices
### 1. Test File Management
- Always clean up test files after tests
- Use unique filenames to avoid conflicts
- Store test files in `tests/tmp/` directory
- Add `tests/tmp/` to `.gitignore`
### 2. Network Simulation
- Test with realistic network conditions
- Simulate network failures and recovery
- Test on slow connections (throttling)
- Validate behavior during high latency
### 3. State Management
- Verify component state after each action
- Test state persistence across interactions
- Ensure proper cleanup on errors
- Validate state synchronization
### 4. Performance Testing
- Test with various file sizes (1MB, 10MB, 50MB)
- Monitor memory usage during uploads
- Measure throughput and latency
- Validate resource cleanup
### 5. Error Handling
- Test all failure scenarios
- Verify error messages are user-friendly
- Ensure proper cleanup on errors
- Test recovery mechanisms
## Resources
- [LiveComponents Upload Guide](../../../src/Framework/LiveComponents/docs/UPLOAD-GUIDE.md)
- [Playwright Testing Documentation](https://playwright.dev/docs/intro)
- [Web Crypto API - SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto)
- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
## Support
For issues or questions:
1. Review this documentation
2. Check test output for specific errors
3. Consult LiveComponents upload guide
4. Review Playwright documentation for browser automation issues
5. Create GitHub issue with test output and error logs

View File

@@ -0,0 +1,720 @@
# LiveComponents E2E Integration Tests
Comprehensive end-to-end integration tests for LiveComponents cross-cutting features: **Partial Rendering**, **Batch Operations**, and **Server-Sent Events (SSE)**.
## Overview
This test suite validates the seamless integration of multiple LiveComponents features working together in real-world scenarios. It ensures that partial rendering, batch operations, and real-time updates work harmoniously without conflicts or state inconsistencies.
## Test Categories
### 1. Partial Rendering Tests (6 tests)
**Purpose**: Validate fragment-based updates without full component re-rendering
**Tests**:
1. **Update only targeted fragment without full component re-render**
- Triggers fragment-specific action
- Verifies only fragment HTML updated
- Ensures component timestamp unchanged (no full render)
- Validates DOM patch efficiency
2. **Update multiple fragments in single request**
- Updates 2+ fragments simultaneously
- Verifies all fragments updated correctly
- Ensures only 1 HTTP request sent
- Validates network efficiency
3. **Preserve component state during partial render**
- Sets component state before fragment update
- Triggers fragment update
- Verifies state preserved after update
- Validates state consistency
4. **Handle nested fragment updates**
- Updates parent fragment containing child fragment
- Verifies parent and child both updated
- Ensures sibling fragments NOT updated
- Validates selective rendering
5. **Apply morphing algorithm for minimal DOM changes**
- Counts DOM nodes before update
- Applies small content change
- Verifies node count unchanged (morphing, not replacement)
- Validates morphing stats (added/removed/updated)
6. **Handle fragment-not-found gracefully**
- Calls action with non-existent fragment ID
- Verifies error message displayed
- Ensures component remains functional
- Validates error recovery
### 2. Batch Operations Tests (6 tests)
**Purpose**: Validate efficient execution of multiple actions in single request
**Tests**:
1. **Execute multiple actions in single batch request**
- Batches 3 different actions
- Verifies only 1 HTTP request sent
- Ensures all actions executed successfully
- Validates batch efficiency
2. **Maintain action execution order in batch**
- Batches 3 actions in specific order
- Logs execution sequence
- Verifies actions executed in correct order
- Validates deterministic execution
3. **Rollback batch on action failure**
- Batches successful + failing + successful actions
- Verifies entire batch rolled back on failure
- Ensures no partial state changes
- Validates transactional behavior
4. **Handle partial batch execution with continueOnError flag**
- Batches actions with `continueOnError: true`
- Includes failing action in middle
- Verifies successful actions still execute
- Validates partial execution mode
5. **Support batch with mixed action types**
- Batches sync + async + fragment update actions
- Verifies all action types execute correctly
- Ensures type compatibility
- Validates heterogeneous batching
6. **Batch state updates efficiently**
- Batches 10 state update actions
- Verifies final state correct
- Ensures only 1 state update event fired (optimization)
- Validates state batching
### 3. Server-Sent Events (SSE) Tests (8 tests)
**Purpose**: Validate real-time server-to-client communication
**Tests**:
1. **Establish SSE connection for real-time updates**
- Enables SSE
- Verifies connection state OPEN (readyState === 1)
- Ensures connection indicator visible
- Validates connection establishment
2. **Receive and apply server-pushed updates**
- Establishes SSE connection
- Simulates server push event
- Verifies component updated automatically
- Validates push handling
3. **Handle SSE reconnection on connection loss**
- Establishes connection
- Simulates connection close
- Verifies reconnecting indicator shown
- Ensures automatic reconnection after retry period
4. **Support multiple SSE event types**
- Sends different event types (update, notification, sync)
- Logs received events
- Verifies all types processed correctly
- Validates event type handling
5. **Batch SSE updates for performance**
- Sends 20 rapid SSE updates
- Verifies final value correct
- Ensures renders < updates (batching optimization)
- Validates performance optimization
6. **Close SSE connection when component unmounts**
- Establishes connection
- Unmounts component
- Verifies connection closed (readyState === 2)
- Validates cleanup
7. **Handle SSE authentication and authorization**
- Tests SSE with valid auth token
- Verifies authenticated connection
- Tests SSE with invalid token
- Ensures auth error displayed
8. **Support SSE with custom event filters**
- Enables SSE with priority filter
- Sends events with different priorities
- Verifies only filtered events processed
- Validates client-side filtering
### 4. Integration Tests (4 tests)
**Purpose**: Validate combined usage of multiple features
**Tests**:
1. **Combine partial rendering with batch operations**
- Batches multiple fragment updates + state changes
- Verifies only 1 request sent
- Ensures all fragments and state updated
- Validates feature synergy
2. **Push partial updates via SSE**
- Sends SSE event with fragment update
- Verifies fragment updated via SSE
- Ensures no full component render
- Validates SSE + partial rendering
3. **Batch SSE-triggered actions efficiently**
- Sends 5 rapid SSE events triggering actions
- Verifies actions batched (< 5 executions)
- Ensures final state correct
- Validates SSE + batching
4. **Maintain consistency across all integration features**
- Complex scenario: batch + partial + SSE simultaneously
- Starts batch with fragment updates
- Sends SSE update during batch execution
- Verifies all updates applied correctly
- Validates state consistency
## Quick Start
### Prerequisites
```bash
# Ensure Playwright is installed
npm install
# Install browsers
npx playwright install chromium
# Ensure development server is running
make up
```
### Running Integration Tests
```bash
# Run all integration tests
npm run test:integration
# Run with visible browser (debugging)
npm run test:integration:headed
# Run in debug mode (step through tests)
npm run test:integration:debug
# Run specific test category
npx playwright test integration.spec.js --grep "Partial Rendering"
npx playwright test integration.spec.js --grep "Batch Operations"
npx playwright test integration.spec.js --grep "Server-Sent Events"
npx playwright test integration.spec.js --grep "Integration:"
```
## Test Page Requirements
### HTML Structure
The test page `/livecomponents/test/integration` must include:
```html
<!DOCTYPE html>
<html>
<head>
<title>LiveComponents Integration Tests</title>
<script src="/assets/livecomponents.js"></script>
</head>
<body>
<!-- Component Container -->
<div data-component="integration:test" data-component-id="integration:test">
<!-- Component Timestamp (für Full Render Detection) -->
<div id="component-timestamp" data-timestamp="initial">initial</div>
<!-- Fragment Rendering Tests -->
<div id="target-fragment">Original Content</div>
<div id="fragment-1">Fragment 1</div>
<div id="fragment-2">Fragment 2</div>
<!-- Nested Fragments -->
<div id="parent-fragment">
<div id="child-fragment">Child</div>
</div>
<div id="sibling-fragment" data-timestamp="original">Sibling</div>
<!-- Morphing Test -->
<div id="morph-fragment">
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</div>
<!-- Batch Operation Tests -->
<div id="counter-value">0</div>
<div id="text-value">Original</div>
<div id="flag-value">false</div>
<!-- SSE Tests -->
<div id="live-value">Initial</div>
<div id="sse-counter">0</div>
<div class="sse-connected" style="display:none">Connected</div>
<div class="sse-reconnecting" style="display:none">Reconnecting...</div>
<div class="sse-authenticated" style="display:none">Authenticated</div>
<div class="sse-auth-error" style="display:none">Auth Error</div>
<!-- State Management -->
<input type="text" id="state-input" />
<!-- Action Buttons -->
<button id="update-fragment">Update Fragment</button>
<button id="update-multiple-fragments">Update Multiple</button>
<button id="save-state">Save State</button>
<button id="update-nested-fragment">Update Nested</button>
<button id="small-update">Small Update</button>
<button id="enable-sse">Enable SSE</button>
<button id="trigger-action">Trigger Action</button>
<!-- Error Display -->
<div class="fragment-error" style="display:none"></div>
<div class="batch-error" style="display:none"></div>
<div class="partial-success" style="display:none"></div>
<!-- Results -->
<div id="sync-result" style="display:none"></div>
<div id="async-result" style="display:none"></div>
<div id="fragment-result" style="display:none"></div>
<div class="action-success" style="display:none"></div>
</div>
<script>
// Global tracking variables
window.__fragmentUpdateCount = 0;
window.__requestCount = 0;
window.__renderCount = 0;
window.__stateUpdateCount = 0;
window.__actionCount = 0;
window.__originalSiblingTimestamp = document.getElementById('sibling-fragment').getAttribute('data-timestamp');
window.__originalComponentTimestamp = document.getElementById('component-timestamp').textContent;
// Morphing stats tracking
window.__morphingStats = {
nodesAdded: 0,
nodesRemoved: 0,
nodesUpdated: 0
};
// Initialize LiveComponents
document.addEventListener('DOMContentLoaded', () => {
window.LiveComponents.init();
});
</script>
</body>
</html>
```
### Component Actions
The `IntegrationTestComponent` must implement:
```php
use App\Framework\LiveComponents\LiveComponent;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\Fragment;
final readonly class IntegrationTestComponent extends LiveComponent
{
#[Action]
#[Fragment('target-fragment')]
public function updateFragment(array $params = []): array
{
$fragmentId = $params['fragmentId'] ?? 'target-fragment';
if (!$this->hasFragment($fragmentId)) {
return ['error' => 'Fragment not found'];
}
return [
'fragments' => [
$fragmentId => '<div id="' . $fragmentId . '">Updated</div>'
]
];
}
#[Action]
public function updateMultipleFragments(): array
{
return [
'fragments' => [
'fragment-1' => '<div id="fragment-1">Fragment 1 Updated</div>',
'fragment-2' => '<div id="fragment-2">Fragment 2 Updated</div>'
]
];
}
#[Action]
public function saveState(string $value): void
{
$this->state->set('savedValue', $value);
}
#[Action]
#[Fragment('parent-fragment')]
public function updateNestedFragment(): array
{
return [
'fragments' => [
'parent-fragment' => '
<div id="parent-fragment">Parent Updated
<div id="child-fragment">Child Updated</div>
</div>
'
]
];
}
#[Action]
#[Fragment('morph-fragment')]
public function smallUpdate(): array
{
return [
'fragments' => [
'morph-fragment' => '
<div id="morph-fragment">
<p>Paragraph 1 Updated</p>
<p>Paragraph 2</p>
</div>
'
]
];
}
#[Action]
public function incrementCounter(): void
{
$current = $this->state->get('counter', 0);
$this->state->set('counter', $current + 1);
}
#[Action]
public function updateText(string $text): void
{
$this->state->set('text', $text);
}
#[Action]
public function toggleFlag(): void
{
$current = $this->state->get('flag', false);
$this->state->set('flag', !$current);
}
#[Action]
public function action1(): void
{
// Log execution
$this->logExecution('action1');
}
#[Action]
public function action2(): void
{
$this->logExecution('action2');
}
#[Action]
public function action3(): void
{
$this->logExecution('action3');
}
#[Action]
public function failingAction(): void
{
throw new \RuntimeException('Intentional failure for testing');
}
#[Action]
public function syncAction(): array
{
return ['success' => true];
}
#[Action]
public function asyncAction(): array
{
// Simulate async operation
usleep(100000); // 100ms
return ['success' => true];
}
#[Action]
#[Fragment('fragment-result')]
public function fragmentAction(): array
{
return [
'fragments' => [
'fragment-result' => '<div id="fragment-result">Fragment Action Complete</div>'
]
];
}
public function enableSSE(array $options = []): void
{
// SSE setup logic
$this->sseEnabled = true;
}
public function validateStateConsistency(): bool
{
// State validation logic
return true;
}
}
```
### SSE Endpoint
The SSE endpoint `/live-component/sse/{componentId}` must support:
```php
use App\Framework\LiveComponents\SSE\SseStream;
final readonly class LiveComponentSseController
{
#[Route('/live-component/sse/{componentId}', method: Method::GET)]
public function stream(string $componentId, Request $request): Response
{
$authToken = $request->headers->get('Authorization');
if (!$this->validateAuthToken($authToken)) {
return new Response(status: Status::UNAUTHORIZED);
}
$stream = new SseStream();
// Set headers
$response = new Response(
status: Status::OK,
headers: [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive'
]
);
// Event loop
while ($connection->isAlive()) {
$event = $this->eventQueue->poll($componentId);
if ($event) {
$stream->send($event->type, $event->data);
}
usleep(100000); // 100ms polling interval
}
return $response;
}
}
```
## Performance Expectations
### Partial Rendering
- **Fragment Update Latency**: <50ms for small fragments (<5KB)
- **DOM Morphing Efficiency**: 0 node additions/removals for content-only changes
- **Memory Impact**: <1MB per fragment update
### Batch Operations
- **Network Savings**: 1 request for N actions (vs. N requests)
- **Execution Time**: Linear O(N) for N batched actions
- **Rollback Overhead**: <10ms for transaction rollback
### Server-Sent Events
- **Connection Establishment**: <500ms
- **Event Latency**: <100ms from server send to client receive
- **Reconnection Time**: <2s after connection loss
- **Batching Window**: 50-100ms for update batching
## Troubleshooting
### Fragment Updates Not Working
**Symptoms**:
- Fragment content doesn't update
- Full component re-renders instead
**Solutions**:
1. **Verify Fragment Attribute**:
```php
#[Fragment('target-fragment')]
public function updateFragment(): array
```
2. **Check Fragment ID in Response**:
```php
return [
'fragments' => [
'target-fragment' => '<div id="target-fragment">Updated</div>'
]
];
```
3. **Ensure Fragment Exists in DOM**:
```html
<div id="target-fragment">Original</div>
```
### Batch Operations Failing
**Symptoms**:
- Individual actions execute separately
- Multiple HTTP requests sent
**Solutions**:
1. **Use Batch API Correctly**:
```javascript
component.batch()
.call('action1')
.call('action2')
.execute(); // Don't forget .execute()
```
2. **Check Batch Support**:
```javascript
if (component.supportsBatch) {
// Batching available
}
```
### SSE Connection Issues
**Symptoms**:
- SSE connection not established
- Events not received
**Solutions**:
1. **Check SSE Endpoint**:
```bash
curl -N https://localhost/live-component/sse/integration:test \
-H "Authorization: Bearer token"
```
2. **Verify Headers**:
```
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
```
3. **Test Auth Token**:
```javascript
component.enableSSE({ authToken: 'valid-token-123' });
```
4. **Check Browser Console**:
```javascript
component.sse.addEventListener('error', (e) => {
console.error('SSE Error:', e);
});
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start dev server
run: |
make up
sleep 10
- name: Run Integration Tests
run: npm run test:integration
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: integration-test-results
path: test-results/
- name: Upload test report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
```
## Best Practices
### 1. Test Organization
- Group related tests with `test.describe()`
- Use descriptive test names
- Follow AAA pattern (Arrange, Act, Assert)
### 2. State Management
- Reset component state between tests
- Use `beforeEach` for consistent setup
- Avoid test interdependencies
### 3. Async Handling
- Use `page.waitForTimeout()` conservatively
- Prefer `page.waitForSelector()` or `page.waitForFunction()`
- Set appropriate timeouts for network operations
### 4. Error Handling
- Test both success and failure paths
- Verify error messages and recovery
- Check graceful degradation
### 5. Performance Testing
- Track request counts
- Measure render times
- Monitor memory usage
- Validate batching efficiency
## Resources
- [Playwright Documentation](https://playwright.dev/)
- [LiveComponents Framework Guide](../../../docs/livecomponents/README.md)
- [Fragment Rendering Guide](../../../docs/livecomponents/FRAGMENTS.md)
- [Batch Operations Guide](../../../docs/livecomponents/BATCH.md)
- [SSE Integration Guide](../../../docs/livecomponents/SSE.md)
## Support
For issues or questions:
1. Check troubleshooting section above
2. Review LiveComponents documentation
3. Inspect browser console and network tab
4. Run tests in headed mode for visual debugging
5. Create GitHub issue with test output and environment details

View File

@@ -0,0 +1,651 @@
# LiveComponents Concurrent Upload Load Tests
Performance and scalability testing for the LiveComponents chunked upload system under high concurrent load.
## Overview
This test suite validates system behavior under various load conditions:
- **Light Load**: 5 concurrent users, 2 files each (1MB) - Baseline performance
- **Moderate Load**: 10 concurrent users, 3 files each (2MB) - Typical production load
- **Heavy Load**: 20 concurrent users, 5 files each (5MB) - Peak traffic simulation
- **Stress Test**: 50 concurrent users, 2 files each (1MB) - System limits discovery
## Quick Start
### Prerequisites
```bash
# Ensure Playwright is installed
npm install
# Install Chromium browser
npx playwright install chromium
# Ensure development server is running with adequate resources
make up
# For heavy/stress tests, ensure server has sufficient resources:
# - At least 4GB RAM available
# - At least 2 CPU cores
# - Adequate disk I/O capacity
```
### Running Load Tests
```bash
# Run all load tests (WARNING: Resource intensive!)
npm run test:load
# Run specific load test
npx playwright test concurrent-upload-load.spec.js --grep "Light Load"
# Run with visible browser (for debugging)
npm run test:load:headed
# Run in headless mode (recommended for CI/CD)
npm run test:load
```
## Load Test Scenarios
### 1. Light Load Test
**Configuration:**
- **Users**: 5 concurrent
- **Files per User**: 2
- **File Size**: 1MB each
- **Total Data**: 10MB
- **Expected Duration**: <30 seconds
**Performance Thresholds:**
- **Max Duration**: 30 seconds
- **Max Memory**: 200MB
- **Max Avg Response Time**: 1 second
- **Min Success Rate**: 95%
**Use Case:** Baseline performance validation, continuous integration tests
**Example Results:**
```
=== Light Load Test Results ===
Total Duration: 18,234ms
Total Uploads: 10
Successful: 10
Failed: 0
Success Rate: 100.00%
Avg Response Time: 823.45ms
Max Response Time: 1,452ms
Avg Memory: 125.34MB
Max Memory: 178.21MB
```
### 2. Moderate Load Test
**Configuration:**
- **Users**: 10 concurrent
- **Files per User**: 3
- **File Size**: 2MB each
- **Total Data**: 60MB
- **Expected Duration**: <60 seconds
**Performance Thresholds:**
- **Max Duration**: 60 seconds
- **Max Memory**: 500MB
- **Max Avg Response Time**: 2 seconds
- **Min Success Rate**: 90%
**Use Case:** Typical production load simulation, daily performance monitoring
**Example Results:**
```
=== Moderate Load Test Results ===
Total Duration: 47,892ms
Total Uploads: 30
Successful: 28
Failed: 2
Success Rate: 93.33%
Avg Response Time: 1,567.23ms
Max Response Time: 2,891ms
Avg Memory: 342.56MB
Max Memory: 467.89MB
```
### 3. Heavy Load Test
**Configuration:**
- **Users**: 20 concurrent
- **Files per User**: 5
- **File Size**: 5MB each
- **Total Data**: 500MB
- **Expected Duration**: <120 seconds
**Performance Thresholds:**
- **Max Duration**: 120 seconds
- **Max Memory**: 1GB (1024MB)
- **Max Avg Response Time**: 3 seconds
- **Min Success Rate**: 85%
**Use Case:** Peak traffic simulation, capacity planning
**Example Results:**
```
=== Heavy Load Test Results ===
Total Duration: 102,456ms
Total Uploads: 100
Successful: 87
Failed: 13
Success Rate: 87.00%
Avg Response Time: 2,734.12ms
Max Response Time: 4,567ms
Avg Memory: 723.45MB
Max Memory: 956.78MB
```
### 4. Stress Test
**Configuration:**
- **Users**: 50 concurrent
- **Files per User**: 2
- **File Size**: 1MB each
- **Total Data**: 100MB
- **Expected Duration**: <180 seconds
**Performance Thresholds:**
- **Max Duration**: 180 seconds
- **Max Memory**: 2GB (2048MB)
- **Max Avg Response Time**: 5 seconds
- **Min Success Rate**: 80%
**Use Case:** System limits discovery, failure mode analysis
**Example Results:**
```
=== Stress Test Results ===
Total Duration: 156,789ms
Total Uploads: 100
Successful: 82
Failed: 18
Success Rate: 82.00%
Avg Response Time: 4,234.56ms
Max Response Time: 7,891ms
Avg Memory: 1,456.78MB
Max Memory: 1,923.45MB
Total Errors: 18
```
### 5. Queue Management Test
**Tests:** Concurrent upload queue handling with proper limits
**Validates:**
- Maximum concurrent uploads respected (default: 3)
- Queue properly manages waiting uploads
- All uploads eventually complete
- No queue starvation or deadlocks
**Configuration:**
- **Files**: 10 files uploaded simultaneously
- **Expected Max Concurrent**: 3 uploads at any time
**Example Output:**
```
Queue States Captured: 18
Max Concurrent Uploads: 3
Final Completed: 10
Queue Properly Managed: ✅
```
### 6. Resource Cleanup Test
**Tests:** Memory cleanup after concurrent uploads complete
**Validates:**
- Memory properly released after uploads
- No memory leaks
- Garbage collection effective
- System returns to baseline state
**Measurement Points:**
- **Baseline Memory**: Before any uploads
- **After Upload Memory**: Immediately after all uploads complete
- **After Cleanup Memory**: After garbage collection
**Expected Behavior:**
- Memory after cleanup should be <50% of memory increase during uploads
**Example Output:**
```
Memory Usage:
Baseline: 45.23MB
After Uploads: 156.78MB (Δ +111.55MB)
After Cleanup: 52.34MB (Δ +7.11MB from baseline)
Cleanup Effectiveness: 93.6%
```
### 7. Error Recovery Test
**Tests:** System recovery from failures during concurrent uploads
**Validates:**
- Automatic retry on failures
- Failed uploads eventually succeed
- No corruption from partial failures
- Graceful degradation under stress
**Simulation:**
- Every 3rd chunk request fails
- System must retry and complete all uploads
**Expected Behavior:**
- All uploads complete successfully despite failures
- Retry logic handles failures transparently
### 8. Throughput Test
**Tests:** Sustained upload throughput measurement
**Configuration:**
- **Files**: 20 files
- **File Size**: 5MB each
- **Total Data**: 100MB
**Metrics:**
- **Throughput**: Total MB / Total Time (seconds)
- **Expected Minimum**: >1 MB/s on localhost
**Example Output:**
```
Throughput Test Results:
Total Data: 100MB
Total Duration: 67.89s
Throughput: 1.47 MB/s
```
## Performance Metrics Explained
### Duration Metrics
- **Total Duration**: Time from test start to all uploads complete
- **Avg Response Time**: Average time for single upload completion
- **Max Response Time**: Slowest single upload time
- **Min Response Time**: Fastest single upload time
### Success Metrics
- **Total Uploads**: Number of attempted uploads
- **Successful Uploads**: Successfully completed uploads
- **Failed Uploads**: Uploads that failed even after retries
- **Success Rate**: Successful / Total (as percentage)
### Resource Metrics
- **Avg Memory**: Average browser memory usage during test
- **Max Memory**: Peak browser memory usage
- **Memory Delta**: Difference between baseline and peak
### Throughput Metrics
- **Throughput (MB/s)**: Data uploaded per second
- **Formula**: Total MB / Total Duration (seconds)
- **Expected Range**: 1-10 MB/s depending on system
## Understanding Test Results
### Success Criteria
**PASS** - All thresholds met:
- Duration within expected maximum
- Memory usage within limits
- Success rate above minimum
- Avg response time acceptable
⚠️ **WARNING** - Some thresholds exceeded:
- Review specific metrics
- Check server resources
- Analyze error patterns
**FAIL** - Critical thresholds exceeded:
- System unable to handle load
- Investigate bottlenecks
- Scale resources or optimize code
### Interpreting Results
**High Success Rate (>95%)**
- System handling load well
- Retry logic effective
- Infrastructure adequate
**Moderate Success Rate (85-95%)**
- Occasional failures acceptable
- Monitor error patterns
- May need optimization
**Low Success Rate (<85%)**
- System struggling under load
- Critical bottlenecks present
- Immediate action required
**High Memory Usage (>75% of threshold)**
- Potential memory leak
- Inefficient resource management
- Review memory cleanup logic
**Slow Response Times (>75% of threshold)**
- Server bottleneck
- Network congestion
- Database query optimization needed
## Troubleshooting
### Tests Timing Out
**Symptoms:**
- Load tests exceed timeout limits
- Uploads never complete
- Browser hangs or crashes
**Solutions:**
1. **Increase Test Timeout:**
```javascript
test('Heavy Load', async ({ browser }) => {
test.setTimeout(180000); // 3 minutes
// ... test code
});
```
2. **Reduce Load:**
```javascript
const LOAD_TEST_CONFIG = {
heavy: {
users: 10, // Reduced from 20
filesPerUser: 3, // Reduced from 5
fileSizeMB: 2, // Reduced from 5
expectedDuration: 90000 // Adjusted
}
};
```
3. **Check Server Resources:**
```bash
# Monitor server resources during test
docker stats
# Increase Docker resources if needed
# In Docker Desktop: Settings → Resources
```
### High Failure Rate
**Symptoms:**
- Success rate below threshold
- Many upload failures
- Timeout errors
**Solutions:**
1. **Check Server Logs:**
```bash
# View PHP error logs
docker logs php
# View Nginx logs
docker logs nginx
```
2. **Increase Server Resources:**
```bash
# Check current limits
docker exec php php -i | grep memory_limit
# Update php.ini or .env
UPLOAD_MAX_FILESIZE=100M
POST_MAX_SIZE=100M
MEMORY_LIMIT=512M
```
3. **Optimize Queue Configuration:**
```env
UPLOAD_PARALLEL_CHUNKS=5 # Increase concurrent chunks
UPLOAD_CHUNK_SIZE=1048576 # Increase chunk size to 1MB
```
### Memory Issues
**Symptoms:**
- Browser out of memory errors
- System becomes unresponsive
- Test crashes
**Solutions:**
1. **Increase Browser Memory:**
```javascript
const browser = await chromium.launch({
args: [
'--max-old-space-size=4096', // 4GB Node heap
'--disable-dev-shm-usage' // Use /tmp instead of /dev/shm
]
});
```
2. **Reduce File Sizes:**
```javascript
const LOAD_TEST_CONFIG = {
heavy: {
fileSizeMB: 2, // Reduced from 5MB
}
};
```
3. **Run Tests Sequentially:**
```bash
# Run one test at a time
npx playwright test concurrent-upload-load.spec.js --grep "Light Load"
npx playwright test concurrent-upload-load.spec.js --grep "Moderate Load"
# etc.
```
### Network Errors
**Symptoms:**
- Connection refused errors
- Request timeouts
- SSL/TLS errors
**Solutions:**
1. **Verify Server Running:**
```bash
# Check server status
docker ps
# Restart if needed
make down && make up
```
2. **Check SSL Certificates:**
```bash
# Navigate to https://localhost in browser
# Accept self-signed certificate if prompted
```
3. **Increase Network Timeouts:**
```javascript
await page.goto('https://localhost/livecomponents/test/upload', {
timeout: 60000 // 60 seconds
});
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Load Tests
on:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
workflow_dispatch: # Manual trigger
jobs:
load-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start dev server
run: |
make up
sleep 10 # Wait for server to be ready
- name: Run Light Load Test
run: npx playwright test concurrent-upload-load.spec.js --grep "Light Load"
- name: Run Moderate Load Test
run: npx playwright test concurrent-upload-load.spec.js --grep "Moderate Load"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: load-test-results
path: test-results/
- name: Notify on failure
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Load Tests Failed',
body: 'Load tests failed. Check workflow run for details.'
});
```
### Performance Regression Detection
```bash
# Run baseline test and save results
npm run test:load > baseline-results.txt
# After code changes, run again and compare
npm run test:load > current-results.txt
diff baseline-results.txt current-results.txt
# Automated regression check
npm run test:load:regression
```
## Best Practices
### 1. Test Environment
- **Dedicated Server**: Use dedicated test server to avoid interference
- **Consistent Resources**: Same hardware/container specs for reproducibility
- **Isolated Network**: Minimize network variability
- **Clean State**: Reset database/cache between test runs
### 2. Test Execution
- **Start Small**: Begin with light load, increase gradually
- **Monitor Resources**: Watch server CPU, memory, disk during tests
- **Sequential Heavy Tests**: Don't run heavy/stress tests in parallel
- **Adequate Timeouts**: Set realistic timeouts based on load
### 3. Result Analysis
- **Track Trends**: Compare results over time, not single runs
- **Statistical Significance**: Multiple runs for reliable metrics
- **Identify Patterns**: Look for consistent failure patterns
- **Correlate Metrics**: Memory spikes with response time increases
### 4. Continuous Improvement
- **Regular Baselines**: Update baselines as system improves
- **Performance Budget**: Define and enforce performance budgets
- **Proactive Monitoring**: Catch regressions early
- **Capacity Planning**: Use load test data for scaling decisions
## Performance Optimization Tips
### Server-Side
1. **Increase Worker Processes:**
```nginx
# nginx.conf
worker_processes auto;
worker_connections 2048;
```
2. **Optimize PHP-FPM:**
```ini
; php-fpm pool config
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
```
3. **Enable Caching:**
```php
// Cache upload sessions
$this->cache->set(
"upload-session:{$sessionId}",
$sessionData,
ttl: 3600 // 1 hour
);
```
### Client-Side
1. **Optimize Chunk Size:**
```javascript
// Larger chunks = fewer requests but more memory
const CHUNK_SIZE = 1024 * 1024; // 1MB (default: 512KB)
```
2. **Increase Parallel Uploads:**
```javascript
// More parallelism = faster completion but more memory
const MAX_PARALLEL_CHUNKS = 5; // (default: 3)
```
3. **Implement Request Pooling:**
```javascript
// Reuse HTTP connections
const keepAlive = true;
const maxSockets = 10;
```
## Resources
- [Playwright Performance Testing](https://playwright.dev/docs/test-advanced#measuring-performance)
- [Load Testing Best Practices](https://martinfowler.com/articles/practical-test-pyramid.html#LoadTesting)
- [System Scalability Patterns](https://docs.microsoft.com/en-us/azure/architecture/patterns/category/performance-scalability)
## Support
For issues or questions:
1. Review this documentation and troubleshooting section
2. Check server logs and resource usage
3. Analyze test results for patterns
4. Consult LiveComponents upload documentation
5. Create GitHub issue with full test output

View File

@@ -0,0 +1,536 @@
# LiveComponents Performance Benchmarks
Comprehensive performance testing suite comparing Fragment-based rendering vs Full HTML rendering in LiveComponents.
## Overview
This benchmark suite measures and compares:
- **Rendering Speed**: Fragment updates vs Full HTML re-renders
- **Network Payload**: Data transfer size for different update strategies
- **DOM Manipulation Overhead**: Client-side update performance
- **Memory Consumption**: Memory footprint during updates
- **Cache Effectiveness**: Performance improvements from caching
- **Scalability**: Performance under different load scenarios
## Quick Start
### Prerequisites
```bash
# Ensure Playwright is installed
npm install
# Install browser binaries
npx playwright install chromium
# Ensure development server is running
make up
```
### Running Benchmarks
```bash
# Run all performance benchmarks
npx playwright test performance-benchmarks.spec.js
# Run specific benchmark
npx playwright test performance-benchmarks.spec.js --grep "Single small fragment"
# Run with visible browser (for debugging)
npx playwright test performance-benchmarks.spec.js --headed
# Run and generate report
npx playwright test performance-benchmarks.spec.js && node tests/e2e/livecomponents/generate-performance-report.js
```
### Generating Reports
```bash
# Generate both HTML and Markdown reports
node tests/e2e/livecomponents/generate-performance-report.js
# Generate only HTML report
node tests/e2e/livecomponents/generate-performance-report.js --format=html
# Generate only Markdown report
node tests/e2e/livecomponents/generate-performance-report.js --format=markdown
```
Reports are generated in `test-results/`:
- `performance-report.html` - Interactive HTML report with styling
- `performance-report.md` - Markdown report for documentation
- `benchmark-results.json` - Raw benchmark data
## Benchmark Scenarios
### 1. Single Small Fragment Update
**Tests:** Counter increment with single fragment vs full render
**Metrics:**
- Fragment update time (expected: <50ms)
- Full render time (expected: <150ms)
- Speedup percentage
**Use Case:** Small, frequent updates like notification badges, counters
### 2. Multiple Fragment Updates (5 fragments)
**Tests:** Updating 5 independent fragments simultaneously vs full render
**Metrics:**
- Fragment update time (expected: <100ms)
- Full render time (expected: <300ms)
- Speedup percentage
**Use Case:** Dashboard widgets, multi-section updates
### 3. Large Component Update (100 items)
**Tests:** Updating large list component with 100 items
**Metrics:**
- Fragment update time (expected: <200ms)
- Full render time (expected: <500ms)
- Speedup percentage
**Use Case:** Product lists, search results, data tables
### 4. Network Payload Size Comparison
**Tests:** Comparing data transfer sizes
**Metrics:**
- Fragment payload size (expected: <5KB)
- Full HTML payload size (expected: <50KB)
- Reduction percentage
**Use Case:** Bandwidth optimization, mobile performance
### 5. Rapid Successive Updates (10 updates)
**Tests:** 10 consecutive updates as fast as possible
**Metrics:**
- Total fragment update time (expected: <500ms)
- Total full render time (expected: <1500ms)
- Average per-update time
- Speedup multiplier
**Use Case:** Real-time data updates, live feeds, typing indicators
### 6. DOM Manipulation Overhead
**Tests:** Breaking down update time into network/server vs DOM manipulation
**Metrics:**
- Pure DOM update time (expected: <5ms)
- Network + server time (expected: <100ms)
- Total fragment update time (expected: <150ms)
**Use Case:** Understanding performance bottlenecks
### 7. Memory Consumption Comparison
**Tests:** Memory usage over 50 updates (Chromium only - uses `performance.memory`)
**Metrics:**
- Fragment updates memory delta (expected: <1MB)
- Full renders memory delta (expected: <2MB)
- Memory reduction percentage
**Use Case:** Long-running applications, memory leak detection
### 8. Cache Effectiveness
**Tests:** Performance improvement from caching
**Metrics:**
- First update time (cold cache) (expected: <100ms)
- Average cached update time (expected: <80ms)
- Cache improvement percentage
**Use Case:** Repeated operations, frequently accessed data
## Performance Thresholds
```javascript
const THRESHOLDS = {
fragmentRender: {
small: 50, // ms for single small fragment
medium: 100, // ms for 5-10 fragments
large: 200 // ms for complex component
},
fullRender: {
small: 150, // ms for full render (small component)
medium: 300, // ms for full render (medium component)
large: 500 // ms for full render (large component)
},
networkPayload: {
fragmentMax: 5000, // bytes for fragment response
fullMax: 50000 // bytes for full HTML response
}
};
```
Thresholds are based on:
- **50ms**: Perceived as instant (Google Core Web Vitals)
- **100ms**: Feels responsive
- **200ms**: Noticeable but acceptable
- **500ms**: Maximum acceptable for interactive operations
## Understanding Results
### Benchmark Output
```
Fragment speedup: 67.3% faster than full render
Fragment: 32.45ms, Full: 98.76ms
✅ Benchmark: Single small fragment update
Fragment Update Time: 32.45ms ≤ 50ms (threshold) ✅
Full Render Time: 98.76ms ≤ 150ms (threshold) ✅
```
### Interpreting Metrics
**Speed Metrics (milliseconds)**:
- **<50ms**: Excellent - Perceived as instant
- **50-100ms**: Good - Feels responsive
- **100-200ms**: Acceptable - Noticeable but smooth
- **>200ms**: Needs improvement - User-noticeable delay
**Payload Size (bytes)**:
- **<1KB**: Excellent - Minimal network overhead
- **1-5KB**: Good - Acceptable for frequent updates
- **5-10KB**: Fair - Consider optimization
- **>10KB**: Large - May impact performance on slow connections
**Memory Delta (KB)**:
- **<100KB**: Excellent - Minimal memory footprint
- **100-500KB**: Good - Acceptable for normal operations
- **500KB-1MB**: Fair - Monitor for leaks
- **>1MB**: High - Investigate potential memory leaks
### Performance Report
The generated HTML report includes:
1. **Executive Summary**
- Average performance improvement percentage
- Best case scenario
- Worst case scenario
2. **Detailed Results**
- All benchmark metrics with pass/fail status
- Grouped by scenario
- Threshold comparisons
3. **Recommendations**
- When to use fragments
- When to use full render
- Performance optimization tips
4. **Metrics Glossary**
- Explanation of each metric
- How to interpret results
## Customizing Benchmarks
### Adding New Benchmarks
```javascript
test('Benchmark: Your custom scenario', async ({ page }) => {
// Measure fragment update
const fragmentTime = await measureActionTime(
page,
'component:id',
'actionName',
{ param: 'value' },
{ fragments: ['#fragment-id'] }
);
// Measure full render
const fullTime = await measureActionTime(
page,
'component:id',
'actionName',
{ param: 'value' }
);
// Store results
storeBenchmarkResult(
'Your Scenario',
'Fragment Update Time',
fragmentTime,
100 // threshold in ms
);
storeBenchmarkResult(
'Your Scenario',
'Full Render Time',
fullTime,
300
);
// Assertions
expect(fragmentTime).toBeLessThan(100);
expect(fragmentTime).toBeLessThan(fullTime);
});
```
### Modifying Thresholds
Edit the `THRESHOLDS` constant in `performance-benchmarks.spec.js`:
```javascript
const THRESHOLDS = {
fragmentRender: {
small: 30, // Stricter threshold
medium: 80,
large: 150
},
// ...
};
```
### Adding Custom Metrics
```javascript
// Custom metric measurement
const customMetric = await page.evaluate(() => {
// Your custom measurement logic
const startTime = performance.now();
// ... perform operation ...
const endTime = performance.now();
return endTime - startTime;
});
// Store custom metric
storeBenchmarkResult(
'Custom Scenario',
'Custom Metric Name',
customMetric,
threshold,
'ms' // or 'bytes', or custom unit
);
```
## Test Page Requirements
Benchmarks assume the following test page exists:
**URL:** `https://localhost/livecomponents/test/performance`
**Required Components:**
1. **Counter Component** (`counter:benchmark`)
- Actions: `increment`, `reset`
- Fragments: `#counter-value`
2. **List Component** (`list:benchmark`)
- Actions: `updateItems({ count: number })`
- Fragments: `#item-1`, `#item-2`, etc.
3. **Product List Component** (`product-list:benchmark`)
- Actions: `loadItems({ count: number })`
- Fragments: `#item-list`
Example test page structure:
```html
<div data-component-id="counter:benchmark">
<div data-lc-fragment="counter-value">0</div>
<button data-action="increment">Increment</button>
<button data-action="reset">Reset</button>
</div>
<div data-component-id="list:benchmark">
<div data-lc-fragment="item-1">Item 1</div>
<div data-lc-fragment="item-2">Item 2</div>
<!-- ... more items ... -->
</div>
<div data-component-id="product-list:benchmark">
<div data-lc-fragment="item-list">
<!-- Product list items -->
</div>
</div>
```
## Continuous Integration
### GitHub Actions Integration
```yaml
name: Performance Benchmarks
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start dev server
run: make up
- name: Run performance benchmarks
run: npx playwright test performance-benchmarks.spec.js
- name: Generate report
if: always()
run: node tests/e2e/livecomponents/generate-performance-report.js
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v3
with:
name: performance-benchmarks
path: |
test-results/benchmark-results.json
test-results/performance-report.html
test-results/performance-report.md
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('test-results/performance-report.md', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: report
});
```
### Performance Regression Detection
Track benchmark results over time and fail CI if performance degrades:
```javascript
// In your CI script
const currentResults = JSON.parse(fs.readFileSync('test-results/benchmark-results.json'));
const baselineResults = JSON.parse(fs.readFileSync('baseline-results.json'));
const regressions = detectRegressions(currentResults, baselineResults, {
threshold: 0.1 // 10% regression tolerance
});
if (regressions.length > 0) {
console.error('Performance regressions detected:');
regressions.forEach(r => console.error(` ${r.scenario}: ${r.metric} - ${r.change}% slower`));
process.exit(1);
}
```
## Troubleshooting
### Benchmarks Timing Out
```javascript
// Increase timeout for specific test
test('slow benchmark', async ({ page }) => {
test.setTimeout(60000); // 60 seconds
// ...
});
```
### Inconsistent Results
**Causes:**
- Network latency variations
- Server load fluctuations
- Background processes
- Browser cache state
**Solutions:**
- Run multiple iterations and average results
- Disable browser cache: `await page.context().clearCookies()`
- Use `--workers=1` for serial execution
- Run on dedicated test infrastructure
### Memory API Not Available
Firefox and Safari don't support `performance.memory`. Memory benchmarks will be skipped on these browsers with a console log:
```
Memory API not available (Firefox/Safari)
```
To test memory consumption, use Chromium:
```bash
npx playwright test performance-benchmarks.spec.js --project=chromium
```
## Best Practices
### Running Benchmarks
1. **Consistent Environment**
- Run on same hardware for comparability
- Close unnecessary applications
- Use stable network connection
- Avoid running during system updates
2. **Multiple Runs**
- Run benchmarks 3-5 times
- Average results for stability
- Discard outliers (>2 standard deviations)
3. **Baseline Tracking**
- Save baseline results for comparison
- Track trends over time
- Alert on significant regressions
### Analyzing Results
1. **Focus on Trends**
- Single outliers may be noise
- Consistent patterns indicate real issues
- Compare relative improvements, not absolute numbers
2. **Context Matters**
- Different devices have different capabilities
- Network conditions affect results
- Browser engines perform differently
3. **Actionable Insights**
- Identify biggest bottlenecks
- Prioritize high-impact optimizations
- Validate improvements with re-runs
## Resources
- [Playwright Performance Testing](https://playwright.dev/docs/test-advanced#measuring-performance)
- [Web Performance Metrics](https://web.dev/metrics/)
- [LiveComponents Performance Guide](../../../src/Framework/LiveComponents/docs/PERFORMANCE-GUIDE.md)
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
## Support
For issues or questions:
1. Review this documentation
2. Check test output for specific errors
3. Consult LiveComponents performance guide
4. Create GitHub issue with benchmark results attached

View File

@@ -0,0 +1,864 @@
# LiveComponents Security E2E Tests
Comprehensive security testing suite for LiveComponents framework covering CSRF protection, rate limiting, idempotency, input sanitization, authorization, and session security.
## Overview
This test suite validates critical security features:
- **CSRF Protection**: Token validation for state-changing actions
- **Rate Limiting**: Protection against abuse and DoS attacks
- **Idempotency**: Preventing duplicate action execution
- **Input Sanitization**: XSS and injection prevention
- **Authorization**: Access control and permissions
- **Session Security**: Session management and hijacking prevention
- **Content Security Policy**: CSP header enforcement
## Quick Start
### Prerequisites
```bash
# Ensure Playwright is installed
npm install
# Install browser binaries
npx playwright install chromium
# Ensure development server is running
make up
```
### Running Security Tests
```bash
# Run all security tests
npm run test:security
# Run specific security test category
npx playwright test security.spec.js --grep "CSRF Protection"
npx playwright test security.spec.js --grep "Rate Limiting"
npx playwright test security.spec.js --grep "Idempotency"
# Run with visible browser (for debugging)
npm run test:security:headed
# Run with debug mode
npm run test:security:debug
```
## Test Categories
### 1. CSRF Protection (4 tests)
**Purpose:** Validate Cross-Site Request Forgery protection for all state-changing actions.
#### Test: CSRF token included in requests
**Validates:**
- CSRF token present in action requests
- Token included in headers (`X-CSRF-Token`) or POST data (`_csrf_token`)
- Framework automatically handles token management
**Expected Behavior:**
```javascript
// Request includes CSRF token
headers: {
'X-CSRF-Token': 'generated-token-value'
}
// OR in POST data
{
_csrf_token: 'generated-token-value',
action: 'increment',
params: { ... }
}
```
#### Test: Reject action without CSRF token
**Validates:**
- Requests without CSRF token are rejected
- Appropriate error message returned
- No state changes occur
**Expected Response:**
```
Status: 403 Forbidden
Error: "CSRF token validation failed"
```
#### Test: Reject action with invalid CSRF token
**Validates:**
- Invalid/expired tokens are rejected
- Token tampering detected
- Security event logged
**Test Approach:**
```javascript
// Replace token with invalid value
headers: { 'X-CSRF-Token': 'invalid-token-12345' }
// Expected: 403 Forbidden
```
#### Test: CSRF token rotation
**Validates:**
- Tokens rotate after usage (optional, framework-dependent)
- New token provided in response
- Old token invalidated
**Rotation Strategy:**
- **Per-request rotation**: New token after each action
- **Time-based rotation**: New token after TTL
- **Session-based**: Token tied to session lifetime
### 2. Rate Limiting (5 tests)
**Purpose:** Prevent abuse through excessive action calls and DoS attacks.
#### Test: Enforce rate limit on rapid calls
**Configuration:**
- **Default Limit**: 10 requests per minute
- **Window**: 60 seconds sliding window
- **Action**: Block excess requests
**Test Approach:**
```javascript
// Trigger 20 rapid action calls
for (let i = 0; i < 20; i++) {
await triggerAction();
}
// Expected: First 10 succeed, remaining 10 blocked
// Success rate: 50% (10/20)
```
**Expected Error:**
```
Status: 429 Too Many Requests
Error: "Rate limit exceeded"
Retry-After: 45 // seconds
```
#### Test: Retry-After header
**Validates:**
- `Retry-After` header present in 429 responses
- Header contains remaining cooldown time
- Client can use header for backoff
**Response Format:**
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
```
#### Test: Rate limit reset after cooldown
**Validates:**
- Rate limit resets after window expires
- Actions allowed again after cooldown
- No lingering restrictions
**Timeline:**
```
00:00 - First 10 requests succeed
00:01 - Next 10 requests blocked (rate limited)
00:01 - Retry-After: 59 seconds
01:00 - Window expires, limit resets
01:01 - New requests succeed
```
#### Test: Separate limits per action type
**Validates:**
- Different actions have independent rate limits
- Hitting limit on action A doesn't affect action B
- Granular control per action
**Configuration:**
```javascript
const RATE_LIMITS = {
'increment': { limit: 10, window: 60 },
'delete': { limit: 5, window: 300 },
'search': { limit: 30, window: 60 }
};
```
#### Test: IP-based rate limiting
**Validates:**
- Rate limits applied per IP address
- Multiple users from same IP share limit
- Different IPs have independent limits
**Scenario:**
```
User A (IP: 192.168.1.1) - 10 requests
User B (IP: 192.168.1.1) - 10 requests
Result: Both users share 10-request limit for that IP
```
### 3. Idempotency (4 tests)
**Purpose:** Ensure critical operations execute exactly once, even with duplicate requests.
#### Test: Handle duplicate calls idempotently
**Validates:**
- Duplicate action calls with same idempotency key are ignored
- Operation executes exactly once
- State changes applied only once
**Test Approach:**
```javascript
// First call with idempotency key
await callAction('increment', { idempotencyKey: 'key-123' });
// Counter: 0 → 1
// Duplicate call (same key)
await callAction('increment', { idempotencyKey: 'key-123' });
// Counter: 1 (unchanged, duplicate ignored)
```
#### Test: Allow different idempotency keys
**Validates:**
- Different idempotency keys allow separate executions
- Each unique key represents distinct operation
- No interference between different keys
**Test Approach:**
```javascript
await callAction('increment', { idempotencyKey: 'key-1' }); // Executes
await callAction('increment', { idempotencyKey: 'key-2' }); // Executes
await callAction('increment', { idempotencyKey: 'key-1' }); // Ignored (duplicate)
```
#### Test: Idempotency key expiration
**Validates:**
- Keys expire after TTL (default: 24 hours)
- Expired keys allow re-execution
- Storage cleanup for old keys
**Timeline:**
```
00:00 - Action with key-123 executes
00:00 - Duplicate with key-123 ignored
24:00 - Key-123 expires
24:01 - Action with key-123 executes again (new operation)
```
#### Test: Cached result for duplicate action
**Validates:**
- Duplicate calls return cached result
- No server-side re-execution
- Consistent response for same key
**Expected Behavior:**
```javascript
const result1 = await callAction('createOrder', {
idempotencyKey: 'order-123'
});
// Returns: { orderId: 'abc', total: 100 }
const result2 = await callAction('createOrder', {
idempotencyKey: 'order-123'
});
// Returns: { orderId: 'abc', total: 100 } (cached, same result)
```
### 4. Input Sanitization & XSS Prevention (5 tests)
**Purpose:** Prevent XSS, injection attacks, and malicious input processing.
#### Test: Sanitize HTML in action parameters
**Validates:**
- HTML tags stripped or escaped
- Script tags prevented from execution
- Safe rendering of user input
**Attack Vectors Tested:**
```javascript
// XSS attempts
'<script>alert("XSS")</script>'
'<img src=x onerror=alert("XSS")>'
'<svg onload=alert("XSS")>'
'<iframe src="javascript:alert(\'XSS\')"></iframe>'
```
**Expected Output:**
```html
<!-- Input: <script>alert("XSS")</script> -->
<!-- Output: &lt;script&gt;alert("XSS")&lt;/script&gt; -->
<!-- Displayed as text, not executed -->
```
#### Test: Escape HTML entities
**Validates:**
- Special characters properly escaped
- No raw HTML injection
- Content-Type headers correct
**Entity Escaping:**
```
< → &lt;
> → &gt;
& → &amp;
" → &quot;
' → &#x27;
```
#### Test: Prevent JavaScript injection
**Validates:**
- `javascript:` protocol blocked in URLs
- Event handlers stripped
- No inline script execution
**Blocked Patterns:**
```javascript
javascript:alert('XSS')
data:text/html,<script>alert('XSS')</script>
vbscript:msgbox("XSS")
```
#### Test: Validate file paths
**Validates:**
- Path traversal attacks prevented
- Only allowed directories accessible
- Absolute paths rejected
**Attack Attempts:**
```
../../../etc/passwd
..\..\windows\system32\config\sam
/etc/passwd
C:\Windows\System32\config\SAM
```
**Expected:** All rejected with "Invalid path" error
#### Test: Prevent SQL injection
**Validates:**
- Parameterized queries used
- SQL keywords escaped
- No raw query concatenation
**Attack Vectors:**
```sql
'; DROP TABLE users; --
' OR '1'='1
admin'--
' UNION SELECT * FROM users--
```
**Expected:** Treated as literal search strings, no SQL execution
### 5. Authorization (3 tests)
**Purpose:** Enforce access control and permission checks.
#### Test: Reject unauthorized action calls
**Validates:**
- Actions requiring authentication are protected
- Anonymous users blocked
- Clear error message
**Protected Action:**
```php
#[Action]
#[RequiresAuth]
public function deleteAllData(): array
{
// Only authenticated users
}
```
**Expected Response:**
```
Status: 401 Unauthorized
Error: "Authentication required"
```
#### Test: Allow authorized action calls
**Validates:**
- Authenticated users can access protected actions
- Valid session/token accepted
- Actions execute successfully
**Flow:**
```
1. User logs in → Session created
2. Call protected action → Success
3. Action executes → Result returned
```
#### Test: Role-based authorization
**Validates:**
- Different roles have different permissions
- Role checks enforced
- Insufficient permissions rejected
**Role Matrix:**
```
Action: deleteAllData
- admin: ✅ Allowed
- moderator: ❌ Forbidden
- user: ❌ Forbidden
Action: editOwnProfile
- admin: ✅ Allowed
- moderator: ✅ Allowed
- user: ✅ Allowed
```
### 6. Session Security (3 tests)
**Purpose:** Secure session management and hijacking prevention.
#### Test: Invalidate session after logout
**Validates:**
- Session properly destroyed on logout
- Session cookie removed
- Subsequent requests rejected
**Flow:**
```
1. Login → Session active
2. Call protected action → Success
3. Logout → Session destroyed
4. Call protected action → 401 Unauthorized
```
#### Test: Detect session hijacking
**Validates:**
- Session binding to IP/User-Agent
- Suspicious activity detected
- Stolen sessions rejected
**Detection Criteria:**
- IP address change
- User-Agent change
- Geo-location anomaly
- Concurrent sessions from different locations
**Response:**
```
Status: 403 Forbidden
Error: "Session invalid - security violation detected"
Action: Session terminated, user notified
```
#### Test: Enforce session timeout
**Validates:**
- Sessions expire after inactivity
- Timeout configurable (default: 30 minutes)
- Expired sessions rejected
**Timeline:**
```
00:00 - Login
00:15 - Action call (session refreshed)
00:45 - No activity for 30 minutes
00:46 - Action call → 401 Session Expired
```
### 7. Content Security Policy (2 tests)
**Purpose:** Enforce CSP headers to prevent injection attacks.
#### Test: CSP headers present
**Validates:**
- `Content-Security-Policy` header set
- Proper directives configured
- No unsafe configurations
**Expected Header:**
```http
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-xyz123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' wss://localhost;
font-src 'self' https://fonts.gstatic.com;
```
#### Test: Block inline scripts via CSP
**Validates:**
- Inline scripts blocked by CSP
- Console errors for violations
- CSP reports generated
**Violation Detection:**
```javascript
// Attempt to inject inline script
const script = document.createElement('script');
script.textContent = 'alert("XSS")';
document.body.appendChild(script);
// Expected Console Error:
// "Refused to execute inline script because it violates
// Content Security Policy directive: 'script-src self'"
```
## Test Page Requirements
Tests assume the following test page at `https://localhost/livecomponents/test/security`:
### Required HTML Elements
```html
<div data-component-id="counter:test">
<div id="counter-value">0</div>
<button id="trigger-action">Trigger Action</button>
<div class="action-success" style="display:none">Success</div>
<div class="error-message" style="display:none"></div>
<div class="rate-limit-error" style="display:none" data-retry-after=""></div>
</div>
<div data-component-id="text:test">
<div id="text-display"></div>
</div>
<div data-component-id="admin:test">
<div class="authorization-error" style="display:none"></div>
</div>
<!-- Login Form -->
<form id="login-form">
<input type="text" id="username" name="username" />
<input type="password" id="password" name="password" />
<button type="submit" id="login-btn">Login</button>
</form>
<div class="logged-in-indicator" style="display:none"></div>
<button id="logout-btn" style="display:none">Logout</button>
<!-- CSRF Token -->
<meta name="csrf-token" content="generated-token-value">
```
### Required Component Actions
```php
final readonly class SecurityTestComponent extends LiveComponent
{
#[Action]
#[RateLimit(requests: 10, window: 60)]
public function triggerAction(): array
{
return ['success' => true];
}
#[Action]
public function increment(string $idempotencyKey = null): array
{
// Idempotent increment
if ($idempotencyKey && $this->hasExecuted($idempotencyKey)) {
return $this->getCachedResult($idempotencyKey);
}
$newValue = $this->state->get('counter') + 1;
$this->state->set('counter', $newValue);
$result = ['value' => $newValue];
if ($idempotencyKey) {
$this->cacheResult($idempotencyKey, $result);
}
return $result;
}
#[Action]
public function setText(string $text): array
{
// Sanitize HTML
$sanitizedText = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
$this->state->set('text', $sanitizedText);
return ['text' => $sanitizedText];
}
#[Action]
#[RequiresAuth]
#[RequiresRole('admin')]
public function performAdminAction(): array
{
return ['success' => true];
}
}
```
## Configuration
### Environment Variables
```env
# CSRF Protection
CSRF_TOKEN_TTL=7200 # 2 hours
CSRF_ROTATE_ON_ACTION=false # Rotate token after each action
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_DEFAULT=60 # Requests per window
RATE_LIMIT_WINDOW=60 # Window in seconds
RATE_LIMIT_STORAGE=redis # Storage backend
# Idempotency
IDEMPOTENCY_ENABLED=true
IDEMPOTENCY_TTL=86400 # 24 hours
IDEMPOTENCY_STORAGE=redis
# Session Security
SESSION_LIFETIME=1800 # 30 minutes
SESSION_SECURE=true # HTTPS only
SESSION_HTTP_ONLY=true # No JavaScript access
SESSION_SAME_SITE=strict # SameSite cookie attribute
SESSION_BIND_IP=true # Bind to IP address
SESSION_BIND_USER_AGENT=true # Bind to User-Agent
# Content Security Policy
CSP_ENABLED=true
CSP_REPORT_ONLY=false # Enforce CSP (not just report)
CSP_REPORT_URI=/csp-report # CSP violation reporting endpoint
```
## Troubleshooting
### CSRF Tests Failing
**Symptoms:**
- CSRF token not found in requests
- All CSRF tests failing
**Solutions:**
1. **Verify CSRF middleware enabled:**
```php
// In middleware stack
new CsrfMiddleware($csrfTokenGenerator)
```
2. **Check meta tag presence:**
```html
<meta name="csrf-token" content="...">
```
3. **Verify JavaScript includes token:**
```javascript
const token = document.querySelector('meta[name="csrf-token"]').content;
// Include in requests
```
### Rate Limiting Not Working
**Symptoms:**
- All rapid requests succeed
- No 429 responses
**Solutions:**
1. **Check rate limit configuration:**
```bash
# Verify environment variables
docker exec php php -r "echo getenv('RATE_LIMIT_ENABLED');"
```
2. **Verify rate limiter initialized:**
```php
// Check DI container
$rateLimiter = $container->get(RateLimiter::class);
```
3. **Check storage backend:**
```bash
# For Redis backend
docker exec redis redis-cli KEYS "rate_limit:*"
```
### Idempotency Issues
**Symptoms:**
- Duplicate requests execute
- Idempotency keys not working
**Solutions:**
1. **Check idempotency key format:**
```javascript
// Must be unique per operation
idempotencyKey: `${userId}-${operationId}-${timestamp}`
```
2. **Verify storage:**
```bash
# For Redis
docker exec redis redis-cli KEYS "idempotency:*"
```
3. **Check TTL:**
```bash
# Verify keys not expiring too quickly
docker exec redis redis-cli TTL "idempotency:key-123"
```
### Authorization Failures
**Symptoms:**
- Authorized users rejected
- Role checks failing
**Solutions:**
1. **Verify session active:**
```javascript
const sessionActive = await page.evaluate(() => {
return window.__sessionActive === true;
});
```
2. **Check role assignment:**
```php
// Verify user roles
$user->hasRole('admin'); // Should return true
```
3. **Review authorization middleware:**
```php
// Ensure middleware in correct order
[AuthMiddleware, RoleMiddleware, ...]
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Security Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * *' # Daily
jobs:
security-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start dev server
run: make up
- name: Run security tests
run: npm run test:security
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: security-test-results
path: test-results/
- name: Security alert on failure
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🚨 Security Tests Failed',
labels: ['security', 'urgent'],
body: 'Security tests failed. Immediate review required.'
});
```
## Best Practices
### 1. Regular Security Testing
- Run security tests before every deployment
- Include in CI/CD pipeline
- Monitor for new vulnerabilities
- Update tests for new attack vectors
### 2. Defense in Depth
- Multiple security layers (CSRF + Rate Limit + Auth)
- Fail securely (block by default, allow explicitly)
- Log all security events
- Alert on suspicious patterns
### 3. Security Monitoring
- Track failed authentication attempts
- Monitor rate limit violations
- Alert on XSS attempts
- Log authorization failures
### 4. Incident Response
- Defined escalation procedures
- Security event playbooks
- Regular security drills
- Post-incident analysis
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- [Content Security Policy Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
- [Rate Limiting Best Practices](https://cloud.google.com/architecture/rate-limiting-strategies-techniques)
## Support
For security issues or questions:
1. Review this documentation
2. Check framework security documentation
3. Consult OWASP guidelines
4. **Report security vulnerabilities privately** to security@example.com
5. Create GitHub issue for non-security test failures
For security issues or questions:
1. Review this documentation
2. Check framework security documentation
3. Consult OWASP guidelines
4. **Report security vulnerabilities privately** to security@example.com
5. Create GitHub issue for non-security test failures

View File

@@ -0,0 +1,596 @@
/**
* E2E Tests for LiveComponents Chunked Upload System
*
* Tests chunked upload functionality in real browser environment:
* - Upload initialization and session management
* - Chunk splitting and parallel uploads
* - Progress tracking via SSE
* - Resume capability after interruption
* - Integrity verification (SHA-256)
* - Error handling and retry logic
* - Quarantine system integration
* - Multiple file uploads
*
* Run with: npx playwright test chunked-upload.spec.js
*/
import { test, expect } from '@playwright/test';
import { createReadStream } from 'fs';
import { writeFile, unlink, mkdir } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Test file generation
const TEST_FILES_DIR = join(__dirname, '../../tmp/upload-test-files');
/**
* Helper: Create test file of specified size
*/
async function createTestFile(filename, sizeInMB) {
await mkdir(TEST_FILES_DIR, { recursive: true });
const filePath = join(TEST_FILES_DIR, filename);
const sizeInBytes = sizeInMB * 1024 * 1024;
// Generate random data
const chunkSize = 1024 * 1024; // 1MB chunks
const chunks = Math.ceil(sizeInBytes / chunkSize);
const buffer = Buffer.alloc(chunkSize);
let written = 0;
const stream = require('fs').createWriteStream(filePath);
for (let i = 0; i < chunks; i++) {
const remaining = sizeInBytes - written;
const writeSize = Math.min(chunkSize, remaining);
// Fill with pattern for verification
for (let j = 0; j < writeSize; j++) {
buffer[j] = (i + j) % 256;
}
stream.write(buffer.slice(0, writeSize));
written += writeSize;
}
stream.end();
await new Promise((resolve) => stream.on('finish', resolve));
return filePath;
}
/**
* Helper: Clean up test files
*/
async function cleanupTestFiles() {
try {
const fs = await import('fs/promises');
const files = await fs.readdir(TEST_FILES_DIR);
for (const file of files) {
await fs.unlink(join(TEST_FILES_DIR, file));
}
} catch (error) {
// Directory might not exist
}
}
test.describe('Chunked Upload System', () => {
test.beforeAll(async () => {
// Clean up any existing test files
await cleanupTestFiles();
});
test.afterAll(async () => {
// Clean up test files
await cleanupTestFiles();
});
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
await page.waitForFunction(() => window.ChunkedUploader !== undefined);
});
test('should initialize upload session successfully', async ({ page }) => {
// Create small test file (1MB)
const testFile = await createTestFile('test-small.bin', 1);
// Set file input
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Click upload button
await page.click('button#upload-btn');
// Wait for session initialization
await page.waitForTimeout(500);
// Verify session created
const sessionId = await page.evaluate(() => {
return window.__uploadSession?.sessionId;
});
expect(sessionId).toBeTruthy();
expect(sessionId).toMatch(/^[a-f0-9-]{36}$/); // UUID format
});
test('should split file into correct number of chunks', async ({ page }) => {
// Create 2MB file (should result in 4 chunks with 512KB chunk size)
const testFile = await createTestFile('test-chunks.bin', 2);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Wait for chunk splitting
await page.waitForTimeout(500);
const uploadInfo = await page.evaluate(() => {
return {
totalChunks: window.__uploadSession?.totalChunks,
chunkSize: window.__uploadSession?.chunkSize
};
});
expect(uploadInfo.totalChunks).toBe(4); // 2MB / 512KB = 4
expect(uploadInfo.chunkSize).toBe(512 * 1024); // 512KB
});
test('should upload chunks in parallel', async ({ page }) => {
// Create 5MB file
const testFile = await createTestFile('test-parallel.bin', 5);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Monitor network requests
const chunkRequests = [];
page.on('request', request => {
if (request.url().includes('/live-component/') &&
request.url().includes('/chunk')) {
chunkRequests.push({
time: Date.now(),
url: request.url()
});
}
});
await page.click('button#upload-btn');
// Wait for upload to complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Verify parallel uploads (chunks should overlap in time)
expect(chunkRequests.length).toBeGreaterThan(3);
// Check if requests were concurrent (within 100ms window)
const timeWindows = chunkRequests.reduce((windows, req) => {
const window = Math.floor(req.time / 100);
windows[window] = (windows[window] || 0) + 1;
return windows;
}, {});
const hasParallelUploads = Object.values(timeWindows).some(count => count > 1);
expect(hasParallelUploads).toBe(true);
});
test('should track upload progress accurately', async ({ page }) => {
// Create 3MB file
const testFile = await createTestFile('test-progress.bin', 3);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
const progressUpdates = [];
// Monitor progress updates
await page.evaluate(() => {
window.__progressUpdates = [];
const originalLog = console.log;
console.log = function(...args) {
if (args[0] && typeof args[0] === 'string' && args[0].includes('Progress:')) {
window.__progressUpdates.push(parseFloat(args[1]));
}
originalLog.apply(console, args);
};
});
await page.click('button#upload-btn');
// Wait for completion
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Get progress updates
const updates = await page.evaluate(() => window.__progressUpdates || []);
// Verify progress increases monotonically
for (let i = 1; i < updates.length; i++) {
expect(updates[i]).toBeGreaterThanOrEqual(updates[i - 1]);
}
// Verify final progress is 100%
expect(updates[updates.length - 1]).toBeCloseTo(100, 0);
});
test('should verify chunk integrity with SHA-256', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-integrity.bin', 1);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Monitor chunk upload requests
const chunkHashes = [];
page.on('requestfinished', async request => {
if (request.url().includes('/chunk')) {
const postData = request.postDataJSON();
if (postData && postData.chunkHash) {
chunkHashes.push(postData.chunkHash);
}
}
});
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Verify hashes were sent
expect(chunkHashes.length).toBeGreaterThan(0);
// Verify hash format (SHA-256 is 64 hex characters)
chunkHashes.forEach(hash => {
expect(hash).toMatch(/^[a-f0-9]{64}$/);
});
});
test('should handle upload interruption and resume', async ({ page }) => {
// Create larger file (10MB)
const testFile = await createTestFile('test-resume.bin', 10);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Wait for some chunks to upload
await page.waitForTimeout(2000);
// Get current progress
const progressBefore = await page.locator('#progress-text').textContent();
const percentBefore = parseFloat(progressBefore);
// Simulate interruption (reload page)
await page.reload();
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Re-select file
const fileInput2 = page.locator('input[type="file"]');
await fileInput2.setInputFiles(testFile);
// Resume upload
await page.click('button#resume-upload-btn');
// Wait for completion
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Verify upload completed
const finalProgress = await page.locator('#progress-text').textContent();
expect(finalProgress).toBe('100%');
// Verify resume actually happened (should skip uploaded chunks)
const uploadedChunks = await page.evaluate(() => window.__uploadedChunks || 0);
expect(uploadedChunks).toBeGreaterThan(0);
});
test('should retry failed chunks with exponential backoff', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-retry.bin', 2);
// Intercept and fail first chunk upload attempt
let attemptCount = 0;
await page.route('**/live-component/**/chunk/**', (route, request) => {
attemptCount++;
if (attemptCount <= 2) {
// Fail first 2 attempts
route.abort('failed');
} else {
// Allow subsequent attempts
route.continue();
}
});
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Upload should eventually succeed after retries
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Verify retries occurred
expect(attemptCount).toBeGreaterThan(2);
});
test('should handle concurrent multi-file uploads', async ({ page }) => {
// Create multiple test files
const files = [
await createTestFile('test-multi-1.bin', 1),
await createTestFile('test-multi-2.bin', 1),
await createTestFile('test-multi-3.bin', 1)
];
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(files);
// Verify all files queued
const queuedCount = await page.evaluate(() => {
return window.__uploadQueue?.length || 0;
});
expect(queuedCount).toBe(3);
// Start uploads
await page.click('button#upload-all-btn');
// Wait for all uploads to complete
await page.waitForSelector('.all-uploads-complete', { timeout: 60000 });
// Verify all files uploaded
const completedFiles = await page.locator('.uploaded-file').count();
expect(completedFiles).toBe(3);
});
test('should receive real-time progress via SSE', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-sse.bin', 5);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Monitor SSE connections
let sseConnected = false;
page.on('requestfinished', request => {
if (request.url().includes('/sse/upload-progress')) {
sseConnected = true;
}
});
await page.click('button#upload-btn');
// Wait a bit for SSE connection
await page.waitForTimeout(1000);
// Verify SSE connection established
expect(sseConnected).toBe(true);
// Verify progress updates are real-time (not just on completion)
const progressElement = page.locator('#progress-text');
// Should see intermediate progress values
const intermediateProgress = [];
for (let i = 0; i < 5; i++) {
await page.waitForTimeout(500);
const progress = await progressElement.textContent();
intermediateProgress.push(parseFloat(progress));
}
// Should have varying progress values
const uniqueValues = new Set(intermediateProgress);
expect(uniqueValues.size).toBeGreaterThan(1);
});
test('should handle upload cancellation', async ({ page }) => {
// Create larger file
const testFile = await createTestFile('test-cancel.bin', 20);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Wait for upload to start
await page.waitForTimeout(1000);
// Get progress before cancellation
const progressBefore = await page.locator('#progress-text').textContent();
const percentBefore = parseFloat(progressBefore);
expect(percentBefore).toBeGreaterThan(0);
expect(percentBefore).toBeLessThan(100);
// Cancel upload
await page.click('button#cancel-upload-btn');
// Wait for cancellation
await page.waitForTimeout(500);
// Verify upload cancelled
const status = await page.locator('#upload-status').textContent();
expect(status).toContain('Cancelled');
// Verify progress stopped
await page.waitForTimeout(1000);
const progressAfter = await page.locator('#progress-text').textContent();
expect(progressAfter).toBe(progressBefore); // Should not have increased
});
test('should validate file size limits', async ({ page }) => {
// Create file exceeding limit (e.g., 100MB)
const testFile = await createTestFile('test-too-large.bin', 100);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Should show error
await page.waitForSelector('.upload-error', { timeout: 5000 });
const errorMessage = await page.locator('.upload-error').textContent();
expect(errorMessage).toContain('File too large');
});
test('should validate file types', async ({ page }) => {
// Create file with disallowed extension
const testFile = await createTestFile('test-invalid.exe', 1);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Should show error
await page.waitForSelector('.upload-error', { timeout: 5000 });
const errorMessage = await page.locator('.upload-error').textContent();
expect(errorMessage).toContain('File type not allowed');
});
test('should display uploaded file in component', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-display.jpg', 1);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
// Wait for upload complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Wait for component update
await page.waitForTimeout(500);
// Verify file appears in uploaded files list
const uploadedFile = page.locator('[data-lc-fragment="file-list"] li').first();
await expect(uploadedFile).toBeVisible();
const fileName = await uploadedFile.textContent();
expect(fileName).toContain('test-display.jpg');
});
test('should handle quarantine system integration', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-quarantine.bin', 1);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Check for quarantine status
const quarantineStatus = await page.evaluate(() => {
return window.__uploadResult?.quarantineStatus;
});
// Should have a quarantine status (scanning, passed, or failed)
expect(['scanning', 'passed', 'failed']).toContain(quarantineStatus);
});
test('should update component state after successful upload', async ({ page }) => {
// Get initial file count
const initialCount = await page.locator('[data-lc-fragment="file-list"] li').count();
// Create and upload file
const testFile = await createTestFile('test-state.pdf', 1);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Wait for component state update
await page.waitForTimeout(1000);
// Verify file count increased
const finalCount = await page.locator('[data-lc-fragment="file-list"] li').count();
expect(finalCount).toBe(initialCount + 1);
});
test('should handle network interruption gracefully', async ({ page }) => {
// Create test file
const testFile = await createTestFile('test-network.bin', 5);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Simulate network interruption after 2 seconds
setTimeout(() => {
page.route('**/live-component/**', route => route.abort('failed'));
}, 2000);
await page.click('button#upload-btn');
// Should show error or retry notification
await page.waitForSelector('.upload-error, .upload-retrying', { timeout: 10000 });
const status = await page.locator('#upload-status').textContent();
expect(status).toMatch(/Error|Retrying/);
});
});
test.describe('Chunked Upload Performance', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test('should upload 10MB file in under 30 seconds', async ({ page }) => {
const testFile = await createTestFile('test-perf-10mb.bin', 10);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
const startTime = Date.now();
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 30000 });
const duration = Date.now() - startTime;
console.log(`10MB upload took ${duration}ms`);
expect(duration).toBeLessThan(30000); // < 30 seconds
});
test('should handle memory efficiently with large files', async ({ page }) => {
// Monitor memory if available (Chromium only)
const memoryBefore = await page.evaluate(() => {
return performance.memory ? performance.memory.usedJSHeapSize : 0;
});
// Upload 50MB file
const testFile = await createTestFile('test-memory-50mb.bin', 50);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 120000 });
const memoryAfter = await page.evaluate(() => {
return performance.memory ? performance.memory.usedJSHeapSize : 0;
});
if (memoryBefore > 0) {
const memoryIncrease = memoryAfter - memoryBefore;
// Memory increase should be reasonable (not loading entire file into memory)
// Should be < 10MB for 50MB file (chunked processing)
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024);
console.log(`Memory increase: ${(memoryIncrease / 1024 / 1024).toFixed(2)} MB`);
}
});
});

View File

@@ -0,0 +1,621 @@
/**
* LiveComponents Concurrent Upload Load Tests
*
* Tests the upload system under high load with concurrent uploads to validate:
* - System scalability and performance under stress
* - Resource management (memory, CPU, network)
* - Queue management for concurrent uploads
* - Server capacity and response times
* - Error handling under load
* - Recovery mechanisms during high traffic
*
* Run with: npx playwright test concurrent-upload-load.spec.js
*/
import { test, expect } from '@playwright/test';
import { mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
// Load Test Configuration
const LOAD_TEST_CONFIG = {
// Concurrent upload scenarios
light: {
users: 5,
filesPerUser: 2,
fileSizeMB: 1,
expectedDuration: 30000 // 30 seconds
},
moderate: {
users: 10,
filesPerUser: 3,
fileSizeMB: 2,
expectedDuration: 60000 // 60 seconds
},
heavy: {
users: 20,
filesPerUser: 5,
fileSizeMB: 5,
expectedDuration: 120000 // 120 seconds
},
stress: {
users: 50,
filesPerUser: 2,
fileSizeMB: 1,
expectedDuration: 180000 // 180 seconds
}
};
// Performance Thresholds
const PERFORMANCE_THRESHOLDS = {
light: {
maxDuration: 30000, // 30 seconds
maxMemoryMB: 200, // 200MB
maxAvgResponseTime: 1000, // 1 second
minSuccessRate: 0.95 // 95%
},
moderate: {
maxDuration: 60000, // 60 seconds
maxMemoryMB: 500, // 500MB
maxAvgResponseTime: 2000, // 2 seconds
minSuccessRate: 0.90 // 90%
},
heavy: {
maxDuration: 120000, // 120 seconds
maxMemoryMB: 1000, // 1GB
maxAvgResponseTime: 3000, // 3 seconds
minSuccessRate: 0.85 // 85%
},
stress: {
maxDuration: 180000, // 180 seconds
maxMemoryMB: 2000, // 2GB
maxAvgResponseTime: 5000, // 5 seconds
minSuccessRate: 0.80 // 80%
}
};
// Test file directory
const TEST_FILES_DIR = join(process.cwd(), 'tests', 'tmp', 'load-test-files');
/**
* Create test file with specified size
*/
async function createTestFile(filename, sizeInMB) {
await mkdir(TEST_FILES_DIR, { recursive: true });
const filePath = join(TEST_FILES_DIR, filename);
const sizeInBytes = sizeInMB * 1024 * 1024;
const chunkSize = 1024 * 1024; // 1MB chunks
const chunks = Math.ceil(sizeInBytes / chunkSize);
const buffer = Buffer.alloc(chunkSize);
let written = 0;
const stream = require('fs').createWriteStream(filePath);
for (let i = 0; i < chunks; i++) {
const remaining = sizeInBytes - written;
const writeSize = Math.min(chunkSize, remaining);
// Fill with pattern for verification
for (let j = 0; j < writeSize; j++) {
buffer[j] = (i + j) % 256;
}
stream.write(buffer.slice(0, writeSize));
written += writeSize;
}
stream.end();
await new Promise((resolve) => stream.on('finish', resolve));
return filePath;
}
/**
* Cleanup test files
*/
async function cleanupTestFiles() {
if (existsSync(TEST_FILES_DIR)) {
await rm(TEST_FILES_DIR, { recursive: true, force: true });
}
}
/**
* Simulate concurrent user session
*/
async function simulateUserSession(browser, userId, config) {
const context = await browser.newContext();
const page = await context.newPage();
const userMetrics = {
userId,
uploads: [],
totalDuration: 0,
memoryUsage: [],
errors: []
};
try {
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
const startTime = Date.now();
// Upload multiple files concurrently per user
for (let fileIndex = 0; fileIndex < config.filesPerUser; fileIndex++) {
const filename = `user${userId}-file${fileIndex}.bin`;
const testFile = await createTestFile(filename, config.fileSizeMB);
const uploadStart = Date.now();
try {
// Set file input
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
// Start upload
await page.click('button#upload-btn');
// Wait for completion
await page.waitForSelector('.upload-complete', {
timeout: 60000
});
const uploadDuration = Date.now() - uploadStart;
userMetrics.uploads.push({
filename,
duration: uploadDuration,
success: true
});
// Collect memory metrics
const memory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize / (1024 * 1024); // MB
}
return 0;
});
if (memory > 0) {
userMetrics.memoryUsage.push(memory);
}
} catch (error) {
userMetrics.errors.push({
filename,
error: error.message
});
userMetrics.uploads.push({
filename,
duration: Date.now() - uploadStart,
success: false,
error: error.message
});
}
}
userMetrics.totalDuration = Date.now() - startTime;
} finally {
await context.close();
}
return userMetrics;
}
/**
* Aggregate metrics from all user sessions
*/
function aggregateMetrics(allUserMetrics) {
const totalUploads = allUserMetrics.reduce(
(sum, user) => sum + user.uploads.length,
0
);
const successfulUploads = allUserMetrics.reduce(
(sum, user) => sum + user.uploads.filter(u => u.success).length,
0
);
const failedUploads = totalUploads - successfulUploads;
const allDurations = allUserMetrics.flatMap(
user => user.uploads.map(u => u.duration)
);
const avgDuration = allDurations.reduce((sum, d) => sum + d, 0) / allDurations.length;
const maxDuration = Math.max(...allDurations);
const minDuration = Math.min(...allDurations);
const allMemoryUsage = allUserMetrics.flatMap(user => user.memoryUsage);
const avgMemory = allMemoryUsage.length > 0
? allMemoryUsage.reduce((sum, m) => sum + m, 0) / allMemoryUsage.length
: 0;
const maxMemory = allMemoryUsage.length > 0 ? Math.max(...allMemoryUsage) : 0;
const successRate = successfulUploads / totalUploads;
return {
totalUploads,
successfulUploads,
failedUploads,
successRate,
avgDuration,
maxDuration,
minDuration,
avgMemory,
maxMemory,
errors: allUserMetrics.flatMap(user => user.errors)
};
}
test.describe('Concurrent Upload Load Tests', () => {
test.afterEach(async () => {
await cleanupTestFiles();
});
test('Light Load: 5 users, 2 files each (1MB)', async ({ browser }) => {
const config = LOAD_TEST_CONFIG.light;
const thresholds = PERFORMANCE_THRESHOLDS.light;
test.setTimeout(config.expectedDuration);
const startTime = Date.now();
// Simulate concurrent user sessions
const userPromises = Array.from({ length: config.users }, (_, i) =>
simulateUserSession(browser, i + 1, config)
);
const allUserMetrics = await Promise.all(userPromises);
const totalDuration = Date.now() - startTime;
// Aggregate metrics
const metrics = aggregateMetrics(allUserMetrics);
console.log('\n=== Light Load Test Results ===');
console.log(`Total Duration: ${totalDuration}ms`);
console.log(`Total Uploads: ${metrics.totalUploads}`);
console.log(`Successful: ${metrics.successfulUploads}`);
console.log(`Failed: ${metrics.failedUploads}`);
console.log(`Success Rate: ${(metrics.successRate * 100).toFixed(2)}%`);
console.log(`Avg Response Time: ${metrics.avgDuration.toFixed(2)}ms`);
console.log(`Max Response Time: ${metrics.maxDuration}ms`);
console.log(`Avg Memory: ${metrics.avgMemory.toFixed(2)}MB`);
console.log(`Max Memory: ${metrics.maxMemory.toFixed(2)}MB`);
// Assertions
expect(totalDuration).toBeLessThan(thresholds.maxDuration);
expect(metrics.maxMemory).toBeLessThan(thresholds.maxMemoryMB);
expect(metrics.avgDuration).toBeLessThan(thresholds.maxAvgResponseTime);
expect(metrics.successRate).toBeGreaterThanOrEqual(thresholds.minSuccessRate);
});
test('Moderate Load: 10 users, 3 files each (2MB)', async ({ browser }) => {
const config = LOAD_TEST_CONFIG.moderate;
const thresholds = PERFORMANCE_THRESHOLDS.moderate;
test.setTimeout(config.expectedDuration);
const startTime = Date.now();
// Simulate concurrent user sessions
const userPromises = Array.from({ length: config.users }, (_, i) =>
simulateUserSession(browser, i + 1, config)
);
const allUserMetrics = await Promise.all(userPromises);
const totalDuration = Date.now() - startTime;
// Aggregate metrics
const metrics = aggregateMetrics(allUserMetrics);
console.log('\n=== Moderate Load Test Results ===');
console.log(`Total Duration: ${totalDuration}ms`);
console.log(`Total Uploads: ${metrics.totalUploads}`);
console.log(`Successful: ${metrics.successfulUploads}`);
console.log(`Failed: ${metrics.failedUploads}`);
console.log(`Success Rate: ${(metrics.successRate * 100).toFixed(2)}%`);
console.log(`Avg Response Time: ${metrics.avgDuration.toFixed(2)}ms`);
console.log(`Max Response Time: ${metrics.maxDuration}ms`);
console.log(`Avg Memory: ${metrics.avgMemory.toFixed(2)}MB`);
console.log(`Max Memory: ${metrics.maxMemory.toFixed(2)}MB`);
// Assertions
expect(totalDuration).toBeLessThan(thresholds.maxDuration);
expect(metrics.maxMemory).toBeLessThan(thresholds.maxMemoryMB);
expect(metrics.avgDuration).toBeLessThan(thresholds.maxAvgResponseTime);
expect(metrics.successRate).toBeGreaterThanOrEqual(thresholds.minSuccessRate);
});
test('Heavy Load: 20 users, 5 files each (5MB)', async ({ browser }) => {
const config = LOAD_TEST_CONFIG.heavy;
const thresholds = PERFORMANCE_THRESHOLDS.heavy;
test.setTimeout(config.expectedDuration);
const startTime = Date.now();
// Simulate concurrent user sessions
const userPromises = Array.from({ length: config.users }, (_, i) =>
simulateUserSession(browser, i + 1, config)
);
const allUserMetrics = await Promise.all(userPromises);
const totalDuration = Date.now() - startTime;
// Aggregate metrics
const metrics = aggregateMetrics(allUserMetrics);
console.log('\n=== Heavy Load Test Results ===');
console.log(`Total Duration: ${totalDuration}ms`);
console.log(`Total Uploads: ${metrics.totalUploads}`);
console.log(`Successful: ${metrics.successfulUploads}`);
console.log(`Failed: ${metrics.failedUploads}`);
console.log(`Success Rate: ${(metrics.successRate * 100).toFixed(2)}%`);
console.log(`Avg Response Time: ${metrics.avgDuration.toFixed(2)}ms`);
console.log(`Max Response Time: ${metrics.maxDuration}ms`);
console.log(`Avg Memory: ${metrics.avgMemory.toFixed(2)}MB`);
console.log(`Max Memory: ${metrics.maxMemory.toFixed(2)}MB`);
// Assertions
expect(totalDuration).toBeLessThan(thresholds.maxDuration);
expect(metrics.maxMemory).toBeLessThan(thresholds.maxMemoryMB);
expect(metrics.avgDuration).toBeLessThan(thresholds.maxAvgResponseTime);
expect(metrics.successRate).toBeGreaterThanOrEqual(thresholds.minSuccessRate);
});
test('Stress Test: 50 users, 2 files each (1MB)', async ({ browser }) => {
const config = LOAD_TEST_CONFIG.stress;
const thresholds = PERFORMANCE_THRESHOLDS.stress;
test.setTimeout(config.expectedDuration);
const startTime = Date.now();
// Simulate concurrent user sessions
const userPromises = Array.from({ length: config.users }, (_, i) =>
simulateUserSession(browser, i + 1, config)
);
const allUserMetrics = await Promise.all(userPromises);
const totalDuration = Date.now() - startTime;
// Aggregate metrics
const metrics = aggregateMetrics(allUserMetrics);
console.log('\n=== Stress Test Results ===');
console.log(`Total Duration: ${totalDuration}ms`);
console.log(`Total Uploads: ${metrics.totalUploads}`);
console.log(`Successful: ${metrics.successfulUploads}`);
console.log(`Failed: ${metrics.failedUploads}`);
console.log(`Success Rate: ${(metrics.successRate * 100).toFixed(2)}%`);
console.log(`Avg Response Time: ${metrics.avgDuration.toFixed(2)}ms`);
console.log(`Max Response Time: ${metrics.maxDuration}ms`);
console.log(`Avg Memory: ${metrics.avgMemory.toFixed(2)}MB`);
console.log(`Max Memory: ${metrics.maxMemory.toFixed(2)}MB`);
console.log(`Total Errors: ${metrics.errors.length}`);
// Assertions
expect(totalDuration).toBeLessThan(thresholds.maxDuration);
expect(metrics.maxMemory).toBeLessThan(thresholds.maxMemoryMB);
expect(metrics.avgDuration).toBeLessThan(thresholds.maxAvgResponseTime);
expect(metrics.successRate).toBeGreaterThanOrEqual(thresholds.minSuccessRate);
});
test('Queue Management: Verify concurrent upload queue handling', async ({ browser }) => {
test.setTimeout(60000);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Create 10 test files
const testFiles = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createTestFile(`queue-test-${i}.bin`, 1)
)
);
// Upload all files at once
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFiles);
// Start uploads
await page.click('button#upload-btn');
// Monitor queue state
const queueStates = [];
const interval = setInterval(async () => {
const queueState = await page.evaluate(() => {
return {
active: window.__activeUploads || 0,
queued: window.__queuedUploads || 0,
completed: window.__completedUploads || 0
};
});
queueStates.push(queueState);
}, 500);
// Wait for all uploads to complete
await page.waitForSelector('.all-uploads-complete', { timeout: 60000 });
clearInterval(interval);
// Verify queue management
expect(queueStates.length).toBeGreaterThan(0);
// Verify max concurrent uploads never exceeded limit (e.g., 3)
const maxConcurrent = Math.max(...queueStates.map(s => s.active));
expect(maxConcurrent).toBeLessThanOrEqual(3);
// Verify all files eventually completed
const finalState = queueStates[queueStates.length - 1];
expect(finalState.completed).toBe(10);
await context.close();
});
test('Resource Cleanup: Verify memory cleanup after concurrent uploads', async ({ browser }) => {
test.setTimeout(60000);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Measure baseline memory
const baselineMemory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize / (1024 * 1024);
}
return 0;
});
// Upload 5 files concurrently
const testFiles = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
createTestFile(`cleanup-test-${i}.bin`, 2)
)
);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFiles);
await page.click('button#upload-btn');
await page.waitForSelector('.all-uploads-complete', { timeout: 60000 });
// Measure memory after uploads
const afterUploadMemory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize / (1024 * 1024);
}
return 0;
});
// Force garbage collection via reload
await page.reload();
await page.waitForTimeout(2000);
// Measure memory after cleanup
const afterCleanupMemory = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize / (1024 * 1024);
}
return 0;
});
if (baselineMemory > 0) {
console.log(`\nMemory Usage:`);
console.log(`Baseline: ${baselineMemory.toFixed(2)}MB`);
console.log(`After Uploads: ${afterUploadMemory.toFixed(2)}MB`);
console.log(`After Cleanup: ${afterCleanupMemory.toFixed(2)}MB`);
const memoryIncrease = afterUploadMemory - baselineMemory;
const memoryAfterCleanup = afterCleanupMemory - baselineMemory;
// Memory should return close to baseline after cleanup
expect(memoryAfterCleanup).toBeLessThan(memoryIncrease * 0.5);
}
await context.close();
});
test('Error Recovery: System recovers from concurrent upload failures', async ({ browser }) => {
test.setTimeout(60000);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Simulate intermittent failures
let failureCount = 0;
await page.route('**/live-component/**/chunk/**', (route) => {
// Fail every 3rd request
if (failureCount % 3 === 0) {
failureCount++;
route.abort('failed');
} else {
failureCount++;
route.continue();
}
});
// Upload 3 files concurrently
const testFiles = await Promise.all(
Array.from({ length: 3 }, (_, i) =>
createTestFile(`recovery-test-${i}.bin`, 1)
)
);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFiles);
await page.click('button#upload-btn');
// Wait for completion (with retries)
await page.waitForSelector('.all-uploads-complete', { timeout: 60000 });
// Verify all files completed despite failures
const completedFiles = await page.locator('.upload-complete').count();
expect(completedFiles).toBe(3);
await context.close();
});
test('Throughput Test: Measure sustained upload throughput', async ({ browser }) => {
test.setTimeout(120000);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://localhost/livecomponents/test/upload');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Upload 20 files of 5MB each (100MB total)
const totalFiles = 20;
const fileSizeMB = 5;
const totalMB = totalFiles * fileSizeMB;
const startTime = Date.now();
for (let i = 0; i < totalFiles; i++) {
const testFile = await createTestFile(`throughput-test-${i}.bin`, fileSizeMB);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testFile);
await page.click('button#upload-btn');
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Small delay between uploads to avoid overwhelming system
await page.waitForTimeout(100);
}
const totalDuration = (Date.now() - startTime) / 1000; // seconds
const throughputMBps = totalMB / totalDuration;
console.log(`\nThroughput Test Results:`);
console.log(`Total Data: ${totalMB}MB`);
console.log(`Total Duration: ${totalDuration.toFixed(2)}s`);
console.log(`Throughput: ${throughputMBps.toFixed(2)} MB/s`);
// Expect at least 1 MB/s throughput
expect(throughputMBps).toBeGreaterThan(1);
await context.close();
});
});

View File

@@ -0,0 +1,388 @@
/**
* E2E Tests for LiveComponent Fragment Rendering
*
* Tests fragment-based partial rendering in real browser environment.
* Verifies that DomPatcher correctly updates specific DOM fragments
* without full page re-render.
*
* Run with: npx playwright test fragment-rendering.spec.js
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponent Fragment Rendering', () => {
test.beforeEach(async ({ page }) => {
// Navigate to test page (assumes local development server running)
await page.goto('https://localhost/livecomponents/test/counter');
// Wait for LiveComponent to initialize
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test('should patch single fragment without full re-render', async ({ page }) => {
// Get initial HTML of container
const initialHTML = await page.evaluate(() => {
return document.querySelector('[data-component-id="counter:main"]').outerHTML;
});
// Click increment button (triggers fragment update)
await page.click('[data-action="increment"]');
// Wait for fragment update
await page.waitForTimeout(100);
// Get updated HTML
const updatedHTML = await page.evaluate(() => {
return document.querySelector('[data-component-id="counter:main"]').outerHTML;
});
// Verify only counter value fragment changed
const counterValue = await page.textContent('[data-lc-fragment="counter-value"]');
expect(counterValue).toContain('1');
// Verify container structure unchanged (only fragment patched)
expect(updatedHTML).not.toBe(initialHTML);
});
test('should update multiple fragments simultaneously', async ({ page }) => {
// Navigate to shopping cart test page
await page.goto('https://localhost/livecomponents/test/shopping-cart');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Add item to cart (updates cart-items and cart-total fragments)
await page.click('[data-action="addItem"]');
await page.waitForTimeout(100);
// Verify cart items fragment updated
const cartItems = await page.$$('[data-lc-fragment="cart-items"] .cart-item');
expect(cartItems.length).toBe(1);
// Verify cart total fragment updated
const cartTotal = await page.textContent('[data-lc-fragment="cart-total"]');
expect(cartTotal).toContain('€');
});
test('should preserve focus state during fragment update', async ({ page }) => {
// Navigate to form test page
await page.goto('https://localhost/livecomponents/test/form');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Focus on input field
const input = page.locator('input[name="username"]');
await input.focus();
await input.fill('test');
await input.evaluate(el => el.setSelectionRange(2, 4)); // Select "st"
// Trigger fragment update (e.g., validation message)
await page.click('[data-action="validate"]');
await page.waitForTimeout(100);
// Verify focus preserved
const focusedElement = await page.evaluate(() => document.activeElement.name);
expect(focusedElement).toBe('username');
// Verify selection preserved
const selection = await input.evaluate(el => ({
start: el.selectionStart,
end: el.selectionEnd
}));
expect(selection).toEqual({ start: 2, end: 4 });
});
test('should handle nested fragment updates', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/nested-fragments');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Parent fragment contains child fragments
const parentFragment = page.locator('[data-lc-fragment="parent"]');
const childFragment1 = page.locator('[data-lc-fragment="child-1"]');
const childFragment2 = page.locator('[data-lc-fragment="child-2"]');
// Trigger update that affects both child fragments
await page.click('[data-action="updateChildren"]');
await page.waitForTimeout(100);
// Verify both child fragments updated
await expect(childFragment1).toContainText('Updated Child 1');
await expect(childFragment2).toContainText('Updated Child 2');
// Verify parent structure unchanged
await expect(parentFragment).toBeVisible();
});
test('should fall back to full render if fragments not specified', async ({ page }) => {
// Monitor network requests
const responses = [];
page.on('response', response => {
if (response.url().includes('/live-component/')) {
responses.push(response);
}
});
// Click action without fragment specification
await page.click('[data-action="incrementFull"]');
await page.waitForTimeout(100);
// Verify full HTML response (not fragments)
expect(responses.length).toBeGreaterThan(0);
const lastResponse = await responses[responses.length - 1].json();
expect(lastResponse).toHaveProperty('html');
expect(lastResponse.fragments).toBeUndefined();
});
test('should batch multiple fragment updates', async ({ page }) => {
// Monitor network requests
let requestCount = 0;
page.on('request', request => {
if (request.url().includes('/live-component/')) {
requestCount++;
}
});
// Trigger multiple actions rapidly
await page.click('[data-action="increment"]');
await page.click('[data-action="increment"]');
await page.click('[data-action="increment"]');
await page.waitForTimeout(200);
// Verify requests were batched (should be < 3)
expect(requestCount).toBeLessThan(3);
// Verify final state correct (count = 3)
const counterValue = await page.textContent('[data-lc-fragment="counter-value"]');
expect(counterValue).toContain('3');
});
test('should preserve scroll position during fragment update', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/long-list');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Scroll to middle of list
await page.evaluate(() => window.scrollTo(0, 500));
const scrollBefore = await page.evaluate(() => window.scrollY);
// Trigger fragment update (update single list item)
await page.click('[data-action="updateItem"]');
await page.waitForTimeout(100);
// Verify scroll position unchanged
const scrollAfter = await page.evaluate(() => window.scrollY);
expect(scrollAfter).toBe(scrollBefore);
});
test('should handle fragment update errors gracefully', async ({ page }) => {
// Simulate fragment not found scenario
const consoleLogs = [];
page.on('console', msg => {
if (msg.type() === 'warn') {
consoleLogs.push(msg.text());
}
});
// Trigger action that requests non-existent fragment
await page.evaluate(() => {
const component = window.LiveComponents.get('counter:main');
component.call('increment', {}, { fragments: ['#nonexistent'] });
});
await page.waitForTimeout(100);
// Verify warning logged
expect(consoleLogs.some(log => log.includes('Fragment not found'))).toBe(true);
// Verify component still functional (fallback to full render)
const counterValue = await page.textContent('[data-lc-fragment="counter-value"]');
expect(counterValue).toContain('1');
});
test('should update data-attributes in fragments', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/data-attributes');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Get initial data attribute
const initialDataValue = await page.getAttribute('[data-lc-fragment="status"]', 'data-status');
expect(initialDataValue).toBe('pending');
// Trigger status change
await page.click('[data-action="approve"]');
await page.waitForTimeout(100);
// Verify data attribute updated
const updatedDataValue = await page.getAttribute('[data-lc-fragment="status"]', 'data-status');
expect(updatedDataValue).toBe('approved');
});
test('should handle whitespace and formatting changes', async ({ page }) => {
// Trigger update with different whitespace formatting
await page.evaluate(() => {
const component = window.LiveComponents.get('counter:main');
// Server might return formatted HTML with different whitespace
component.call('increment');
});
await page.waitForTimeout(100);
// Verify content updated correctly despite formatting differences
const counterValue = await page.textContent('[data-lc-fragment="counter-value"]');
expect(counterValue.trim()).toBe('1');
});
test('should preserve event listeners on fragments', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/event-listeners');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Attach custom event listener to fragment button
await page.evaluate(() => {
const button = document.querySelector('[data-lc-fragment="action-button"] button');
button.addEventListener('custom-click', () => {
window.customEventTriggered = true;
});
});
// Trigger fragment update (patches button element)
await page.click('[data-action="updateFragment"]');
await page.waitForTimeout(100);
// Trigger custom event
await page.evaluate(() => {
const button = document.querySelector('[data-lc-fragment="action-button"] button');
button.dispatchEvent(new Event('custom-click'));
});
// Verify custom event still works (listener preserved)
const eventTriggered = await page.evaluate(() => window.customEventTriggered);
expect(eventTriggered).toBe(true);
});
});
test.describe('Fragment Rendering Performance', () => {
test('should render fragments faster than full HTML', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/performance');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Measure full render time
const fullRenderStart = Date.now();
await page.evaluate(() => {
window.LiveComponents.get('product-list:main').call('refresh');
});
await page.waitForTimeout(100);
const fullRenderTime = Date.now() - fullRenderStart;
// Measure fragment render time
const fragmentRenderStart = Date.now();
await page.evaluate(() => {
window.LiveComponents.get('product-list:main').call('refresh', {}, {
fragments: ['#product-list-items']
});
});
await page.waitForTimeout(100);
const fragmentRenderTime = Date.now() - fragmentRenderStart;
// Fragment render should be faster
expect(fragmentRenderTime).toBeLessThan(fullRenderTime);
});
test('should reduce network payload with fragments', async ({ page }) => {
const responses = [];
page.on('response', async response => {
if (response.url().includes('/live-component/')) {
const body = await response.text();
responses.push({
type: response.url().includes('fragments') ? 'fragment' : 'full',
size: body.length
});
}
});
// Full render
await page.evaluate(() => {
window.LiveComponents.get('counter:main').call('increment');
});
await page.waitForTimeout(100);
// Fragment render
await page.evaluate(() => {
window.LiveComponents.get('counter:main').call('increment', {}, {
fragments: ['#counter-value']
});
});
await page.waitForTimeout(100);
// Compare payload sizes
const fullResponse = responses.find(r => r.type === 'full');
const fragmentResponse = responses.find(r => r.type === 'fragment');
expect(fragmentResponse.size).toBeLessThan(fullResponse.size);
});
});
test.describe('Fragment Rendering Edge Cases', () => {
test('should handle empty fragments', async ({ page }) => {
await page.evaluate(() => {
const component = window.LiveComponents.get('counter:main');
component.call('clear');
});
await page.waitForTimeout(100);
// Verify empty fragment rendered
const fragmentContent = await page.textContent('[data-lc-fragment="counter-value"]');
expect(fragmentContent.trim()).toBe('');
});
test('should handle very large fragments', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/large-fragment');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Trigger update of large fragment (1000+ items)
const startTime = Date.now();
await page.click('[data-action="loadMore"]');
await page.waitForTimeout(500);
const duration = Date.now() - startTime;
// Should complete in reasonable time (<1 second)
expect(duration).toBeLessThan(1000);
// Verify items rendered
const itemCount = await page.$$eval('[data-lc-fragment="items-list"] .item', items => items.length);
expect(itemCount).toBeGreaterThan(100);
});
test('should handle rapid successive fragment updates', async ({ page }) => {
// Trigger 10 updates rapidly
for (let i = 0; i < 10; i++) {
await page.click('[data-action="increment"]');
}
await page.waitForTimeout(200);
// Verify final state correct
const counterValue = await page.textContent('[data-lc-fragment="counter-value"]');
expect(parseInt(counterValue)).toBe(10);
});
test('should handle fragments with special characters', async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/special-chars');
await page.waitForFunction(() => window.LiveComponents !== undefined);
// Update fragment with special characters (<, >, &, etc.)
await page.click('[data-action="updateSpecialChars"]');
await page.waitForTimeout(100);
// Verify special characters properly escaped
const fragmentText = await page.textContent('[data-lc-fragment="content"]');
expect(fragmentText).toContain('<script>');
expect(fragmentText).toContain('&');
});
});

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env node
/**
* Performance Benchmark Report Generator
*
* Generates HTML and Markdown reports from benchmark-results.json
*
* Usage:
* node tests/e2e/livecomponents/generate-performance-report.js
* node tests/e2e/livecomponents/generate-performance-report.js --format=html
* node tests/e2e/livecomponents/generate-performance-report.js --format=markdown
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Parse command line arguments
const args = process.argv.slice(2);
const format = args.find(arg => arg.startsWith('--format='))?.split('=')[1] || 'both';
// Load benchmark results
const resultsPath = path.join(process.cwd(), 'test-results', 'benchmark-results.json');
if (!fs.existsSync(resultsPath)) {
console.error('❌ No benchmark results found. Run benchmarks first:');
console.error(' npx playwright test performance-benchmarks.spec.js');
process.exit(1);
}
const data = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
const { timestamp, results, summary } = data;
// Group results by scenario
const scenarios = {};
results.forEach(result => {
if (!scenarios[result.scenario]) {
scenarios[result.scenario] = [];
}
scenarios[result.scenario].push(result);
});
/**
* Generate Markdown Report
*/
function generateMarkdownReport() {
let markdown = `# LiveComponents Performance Benchmark Report
**Generated:** ${new Date(timestamp).toLocaleString()}
**Summary:**
- Total Benchmarks: ${summary.total}
- Passed: ${summary.passed}
- Failed: ${summary.failed} ${summary.failed > 0 ? '❌' : ''}
---
## Executive Summary
This report compares the performance characteristics of **Fragment-based rendering** vs **Full HTML rendering** in LiveComponents.
### Key Findings
`;
// Calculate overall speedups
const fragmentVsFull = [];
Object.values(scenarios).forEach(scenarioResults => {
const fragmentResult = scenarioResults.find(r => r.metric.includes('Fragment'));
const fullResult = scenarioResults.find(r => r.metric.includes('Full'));
if (fragmentResult && fullResult && fragmentResult.unit === 'ms' && fullResult.unit === 'ms') {
const speedup = ((fullResult.value - fragmentResult.value) / fullResult.value * 100);
fragmentVsFull.push({
scenario: fragmentResult.scenario,
speedup,
fragmentTime: fragmentResult.value,
fullTime: fullResult.value
});
}
});
if (fragmentVsFull.length > 0) {
const avgSpeedup = fragmentVsFull.reduce((sum, item) => sum + item.speedup, 0) / fragmentVsFull.length;
markdown += `**Average Performance Improvement:** ${avgSpeedup.toFixed(1)}% faster with fragments\n\n`;
const best = fragmentVsFull.reduce((best, item) => item.speedup > best.speedup ? item : best);
markdown += `**Best Case:** ${best.scenario} - ${best.speedup.toFixed(1)}% faster (${best.fragmentTime.toFixed(2)}ms vs ${best.fullTime.toFixed(2)}ms)\n\n`;
const worst = fragmentVsFull.reduce((worst, item) => item.speedup < worst.speedup ? item : worst);
markdown += `**Worst Case:** ${worst.scenario} - ${worst.speedup.toFixed(1)}% faster (${worst.fragmentTime.toFixed(2)}ms vs ${worst.fullTime.toFixed(2)}ms)\n\n`;
}
markdown += `---
## Detailed Results
`;
// Generate detailed results per scenario
Object.entries(scenarios).forEach(([scenarioName, scenarioResults]) => {
markdown += `### ${scenarioName}\n\n`;
markdown += `| Metric | Value | Threshold | Status |\n`;
markdown += `|--------|-------|-----------|--------|\n`;
scenarioResults.forEach(result => {
const value = result.unit === 'bytes'
? `${(result.value / 1024).toFixed(2)} KB`
: `${result.value.toFixed(2)} ${result.unit}`;
const threshold = result.unit === 'bytes'
? `${(result.threshold / 1024).toFixed(2)} KB`
: `${result.threshold} ${result.unit}`;
const status = result.passed ? '✅ Pass' : '❌ Fail';
markdown += `| ${result.metric} | ${value} | ${threshold} | ${status} |\n`;
});
markdown += `\n`;
});
markdown += `---
## Recommendations
Based on the benchmark results:
`;
// Generate recommendations
const recommendations = [];
// Check if fragments are faster
const avgFragmentTime = results
.filter(r => r.metric.includes('Fragment') && r.unit === 'ms')
.reduce((sum, r) => sum + r.value, 0) / results.filter(r => r.metric.includes('Fragment') && r.unit === 'ms').length;
const avgFullTime = results
.filter(r => r.metric.includes('Full') && r.unit === 'ms')
.reduce((sum, r) => sum + r.value, 0) / results.filter(r => r.metric.includes('Full') && r.unit === 'ms').length;
if (avgFragmentTime < avgFullTime) {
const improvement = ((avgFullTime - avgFragmentTime) / avgFullTime * 100).toFixed(1);
recommendations.push(`✅ **Use fragment rendering** for partial updates - Average ${improvement}% performance improvement`);
}
// Check payload sizes
const fragmentPayload = results.find(r => r.metric === 'Fragment Payload Size');
const fullPayload = results.find(r => r.metric === 'Full HTML Payload Size');
if (fragmentPayload && fullPayload) {
const reduction = ((fullPayload.value - fragmentPayload.value) / fullPayload.value * 100).toFixed(1);
recommendations.push(`✅ **Fragment updates reduce network payload** by ${reduction}% on average`);
}
// Check rapid updates
const rapidFragment = results.find(r => r.scenario === 'Rapid Updates (10x)' && r.metric.includes('Fragment'));
const rapidFull = results.find(r => r.scenario === 'Rapid Updates (10x)' && r.metric.includes('Full'));
if (rapidFragment && rapidFull) {
const multiplier = (rapidFull.value / rapidFragment.value).toFixed(1);
recommendations.push(`✅ **For rapid successive updates**, fragments are ${multiplier}x faster`);
}
// Check memory consumption
const memFragment = results.find(r => r.metric === 'Fragment Updates Memory Delta');
const memFull = results.find(r => r.metric === 'Full Renders Memory Delta');
if (memFragment && memFull && memFragment.value < memFull.value) {
const memReduction = ((memFull.value - memFragment.value) / memFull.value * 100).toFixed(1);
recommendations.push(`✅ **Lower memory consumption** with fragments - ${memReduction}% less memory used`);
}
recommendations.forEach(rec => {
markdown += `${rec}\n\n`;
});
markdown += `### When to Use Fragments
- ✅ **Small, frequent updates** (e.g., counters, notifications, status indicators)
- ✅ **Partial form updates** (e.g., validation errors, field suggestions)
- ✅ **List item modifications** (e.g., shopping cart items, task lists)
- ✅ **Real-time data updates** (e.g., live scores, stock prices)
- ✅ **Multi-section updates** (e.g., updating header + footer simultaneously)
### When to Use Full Render
- ⚠️ **Complete layout changes** (e.g., switching views, modal dialogs)
- ⚠️ **Initial page load** (no fragments to update yet)
- ⚠️ **Complex interdependent updates** (easier to re-render entire component)
- ⚠️ **Simple components** (overhead of fragment logic not worth it)
---
## Performance Metrics Glossary
- **Fragment Update Time:** Time to fetch and apply fragment-specific updates
- **Full Render Time:** Time to fetch and replace entire component HTML
- **Network Payload:** Size of data transferred from server to client
- **DOM Update:** Time spent manipulating the Document Object Model
- **Memory Delta:** Change in JavaScript heap memory usage
---
*Generated by LiveComponents Performance Benchmark Suite*
`;
return markdown;
}
/**
* Generate HTML Report
*/
function generateHTMLReport() {
const markdown = generateMarkdownReport();
// Simple markdown to HTML conversion
let html = markdown
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/^---$/gm, '<hr>');
// Wrap in HTML template
const htmlDoc = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponents Performance Benchmark Report</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 3rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 1rem;
margin-bottom: 2rem;
}
h2 {
color: #34495e;
margin-top: 3rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ecf0f1;
}
h3 {
color: #7f8c8d;
margin-top: 2rem;
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background: white;
}
th {
background: #3498db;
color: white;
padding: 1rem;
text-align: left;
font-weight: 600;
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #ecf0f1;
}
tr:hover {
background: #f8f9fa;
}
ul {
list-style-position: inside;
margin: 1rem 0;
}
li {
margin: 0.5rem 0;
padding-left: 1rem;
}
code {
background: #f1f3f4;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
strong {
color: #2c3e50;
font-weight: 600;
}
hr {
border: none;
border-top: 2px solid #ecf0f1;
margin: 2rem 0;
}
.summary-box {
background: #e8f4f8;
border-left: 4px solid #3498db;
padding: 1.5rem;
margin: 2rem 0;
border-radius: 4px;
}
.recommendation {
background: #d4edda;
border-left: 4px solid #28a745;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
}
.warning {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="container">
${html}
</div>
</body>
</html>`;
return htmlDoc;
}
/**
* Main execution
*/
function main() {
console.log('📊 Generating Performance Benchmark Report...\n');
const outputDir = path.join(process.cwd(), 'test-results');
if (format === 'markdown' || format === 'both') {
const markdown = generateMarkdownReport();
const mdPath = path.join(outputDir, 'performance-report.md');
fs.writeFileSync(mdPath, markdown);
console.log(`✅ Markdown report: ${mdPath}`);
}
if (format === 'html' || format === 'both') {
const html = generateHTMLReport();
const htmlPath = path.join(outputDir, 'performance-report.html');
fs.writeFileSync(htmlPath, html);
console.log(`✅ HTML report: ${htmlPath}`);
}
console.log('\n📈 Report Generation Complete!\n');
console.log('Summary:');
console.log(` Total Benchmarks: ${summary.total}`);
console.log(` Passed: ${summary.passed}`);
console.log(` Failed: ${summary.failed} ${summary.failed > 0 ? '❌' : ''}`);
if (format === 'html' || format === 'both') {
console.log(`\n🌐 Open HTML report in browser:`);
console.log(` file://${path.join(outputDir, 'performance-report.html')}`);
}
}
main();

View File

@@ -0,0 +1,715 @@
import { test, expect } from '@playwright/test';
/**
* LiveComponents E2E Integration Tests
*
* Tests für Cross-Cutting Concerns:
* - Partial Rendering (Fragment-basierte Updates)
* - Batch Operations (Mehrere Actions zusammen)
* - Server-Sent Events (Echtzeit-Kommunikation)
*
* @requires Test-Seite unter /livecomponents/test/integration
* @requires LiveComponents Framework initialisiert
*/
test.describe('Partial Rendering', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/integration');
await page.waitForFunction(() => window.LiveComponents !== undefined, { timeout: 5000 });
});
test('should update only targeted fragment without full component re-render', async ({ page }) => {
// Timestamp vor Update erfassen
const initialTimestamp = await page.locator('#component-timestamp').textContent();
// Fragment-spezifische Action triggern
await page.click('button#update-fragment');
await page.waitForTimeout(500);
// Fragment wurde aktualisiert
const fragmentContent = await page.locator('#target-fragment').textContent();
expect(fragmentContent).toContain('Updated');
// Component timestamp NICHT geändert (kein Full Render)
const finalTimestamp = await page.locator('#component-timestamp').textContent();
expect(finalTimestamp).toBe(initialTimestamp);
// Nur Fragment wurde im DOM aktualisiert
const updateCount = await page.evaluate(() => {
return window.__fragmentUpdateCount || 0;
});
expect(updateCount).toBe(1);
});
test('should update multiple fragments in single request', async ({ page }) => {
await page.click('button#update-multiple-fragments');
await page.waitForTimeout(500);
// Beide Fragments aktualisiert
const fragment1 = await page.locator('#fragment-1').textContent();
const fragment2 = await page.locator('#fragment-2').textContent();
expect(fragment1).toContain('Fragment 1 Updated');
expect(fragment2).toContain('Fragment 2 Updated');
// Nur 1 HTTP Request für beide Updates
const requestCount = await page.evaluate(() => {
return window.__requestCount || 0;
});
expect(requestCount).toBe(1);
});
test('should preserve component state during partial render', async ({ page }) => {
// State setzen
await page.fill('input#state-input', 'Test Value');
await page.click('button#save-state');
await page.waitForTimeout(200);
// Fragment Update triggern
await page.click('button#update-fragment');
await page.waitForTimeout(500);
// State wurde NICHT verloren
const inputValue = await page.inputValue('input#state-input');
expect(inputValue).toBe('Test Value');
// State im Component noch vorhanden
const stateValue = await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.state.get('savedValue');
});
expect(stateValue).toBe('Test Value');
});
test('should handle nested fragment updates', async ({ page }) => {
await page.click('button#update-nested-fragment');
await page.waitForTimeout(500);
// Parent fragment aktualisiert
const parentContent = await page.locator('#parent-fragment').textContent();
expect(parentContent).toContain('Parent Updated');
// Child fragment innerhalb von parent auch aktualisiert
const childContent = await page.locator('#child-fragment').textContent();
expect(childContent).toContain('Child Updated');
// Sibling fragment NICHT aktualisiert
const siblingTimestamp = await page.locator('#sibling-fragment').getAttribute('data-timestamp');
const originalTimestamp = await page.evaluate(() => window.__originalSiblingTimestamp);
expect(siblingTimestamp).toBe(originalTimestamp);
});
test('should apply morphing algorithm for minimal DOM changes', async ({ page }) => {
// DOM Nodes vor Update zählen
const nodesBefore = await page.evaluate(() => {
const fragment = document.getElementById('morph-fragment');
return fragment.querySelectorAll('*').length;
});
// Kleines Update im Fragment
await page.click('button#small-update');
await page.waitForTimeout(500);
// DOM Nodes nach Update
const nodesAfter = await page.evaluate(() => {
const fragment = document.getElementById('morph-fragment');
return fragment.querySelectorAll('*').length;
});
// Anzahl Nodes sollte gleich bleiben (nur Content geändert)
expect(nodesAfter).toBe(nodesBefore);
// Morphing Stats prüfen
const morphStats = await page.evaluate(() => window.__morphingStats);
expect(morphStats.nodesAdded).toBe(0);
expect(morphStats.nodesRemoved).toBe(0);
expect(morphStats.nodesUpdated).toBeGreaterThan(0);
});
test('should handle fragment-not-found gracefully', async ({ page }) => {
// Action mit nicht-existierendem Fragment
await page.evaluate(() => {
window.LiveComponents.get('integration:test').call('updateFragment', {
fragmentId: 'non-existent-fragment'
});
});
await page.waitForTimeout(500);
// Error handling
const errorMessage = await page.locator('.fragment-error').textContent();
expect(errorMessage).toContain('Fragment not found');
// Component bleibt funktionsfähig
await page.click('button#update-fragment');
await page.waitForTimeout(500);
const fragmentContent = await page.locator('#target-fragment').textContent();
expect(fragmentContent).toContain('Updated');
});
});
test.describe('Batch Operations', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/integration');
await page.waitForFunction(() => window.LiveComponents !== undefined, { timeout: 5000 });
});
test('should execute multiple actions in single batch request', async ({ page }) => {
const requestsBefore = await page.evaluate(() => window.__requestCount || 0);
// Batch von 3 Actions triggern
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch()
.call('incrementCounter')
.call('updateText', { text: 'Batch Updated' })
.call('toggleFlag')
.execute();
});
await page.waitForTimeout(500);
// Nur 1 zusätzlicher Request für alle 3 Actions
const requestsAfter = await page.evaluate(() => window.__requestCount || 0);
expect(requestsAfter - requestsBefore).toBe(1);
// Alle Actions wurden ausgeführt
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBeGreaterThan(0);
const text = await page.locator('#text-value').textContent();
expect(text).toBe('Batch Updated');
const flag = await page.locator('#flag-value').textContent();
expect(flag).toBe('true');
});
test('should maintain action execution order in batch', async ({ page }) => {
const executionLog = [];
await page.exposeFunction('logExecution', (action) => {
executionLog.push(action);
});
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch()
.call('action1')
.call('action2')
.call('action3')
.execute();
});
await page.waitForTimeout(1000);
// Actions in korrekter Reihenfolge ausgeführt
expect(executionLog).toEqual(['action1', 'action2', 'action3']);
});
test('should rollback batch on action failure', async ({ page }) => {
const initialCounter = await page.locator('#counter-value').textContent();
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch()
.call('incrementCounter') // Erfolgreich
.call('failingAction') // Fehlschlag
.call('incrementCounter') // Sollte nicht ausgeführt werden
.execute();
});
await page.waitForTimeout(500);
// Counter wurde NICHT inkrementiert (Rollback)
const finalCounter = await page.locator('#counter-value').textContent();
expect(finalCounter).toBe(initialCounter);
// Error angezeigt
const errorVisible = await page.locator('.batch-error').isVisible();
expect(errorVisible).toBe(true);
});
test('should handle partial batch execution with continueOnError flag', async ({ page }) => {
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch({ continueOnError: true })
.call('incrementCounter') // Erfolgreich
.call('failingAction') // Fehlschlag
.call('incrementCounter') // Sollte trotzdem ausgeführt werden
.execute();
});
await page.waitForTimeout(500);
// Counter wurde 2x inkrementiert (trotz Fehler in der Mitte)
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBe(2);
// Partial success angezeigt
const partialSuccessVisible = await page.locator('.partial-success').isVisible();
expect(partialSuccessVisible).toBe(true);
});
test('should support batch with mixed action types', async ({ page }) => {
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch()
.call('syncAction') // Synchrone Action
.call('asyncAction') // Asynchrone Action
.call('fragmentAction') // Fragment Update Action
.execute();
});
await page.waitForTimeout(1000);
// Alle Action-Typen erfolgreich
const syncResult = await page.locator('#sync-result').isVisible();
const asyncResult = await page.locator('#async-result').isVisible();
const fragmentResult = await page.locator('#fragment-result').isVisible();
expect(syncResult).toBe(true);
expect(asyncResult).toBe(true);
expect(fragmentResult).toBe(true);
});
test('should batch state updates efficiently', async ({ page }) => {
const stateUpdatesBefore = await page.evaluate(() => {
return window.__stateUpdateCount || 0;
});
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
// 10 State-Updates in Batch
const batch = component.batch();
for (let i = 0; i < 10; i++) {
batch.call('incrementCounter');
}
batch.execute();
});
await page.waitForTimeout(500);
// Counter wurde 10x inkrementiert
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBe(10);
// Nur 1 State-Update Event gefeuert (optimiert)
const stateUpdatesAfter = await page.evaluate(() => {
return window.__stateUpdateCount || 0;
});
expect(stateUpdatesAfter - stateUpdatesBefore).toBe(1);
});
});
test.describe('Server-Sent Events (SSE)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/integration');
await page.waitForFunction(() => window.LiveComponents !== undefined, { timeout: 5000 });
});
test('should establish SSE connection for real-time updates', async ({ page }) => {
// SSE aktivieren
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
// Connection Status prüfen
const connectionStatus = await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.sse?.readyState;
});
expect(connectionStatus).toBe(1); // OPEN
// SSE-Indikator sichtbar
const sseIndicator = await page.locator('.sse-connected').isVisible();
expect(sseIndicator).toBe(true);
});
test('should receive and apply server-pushed updates', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
const initialValue = await page.locator('#live-value').textContent();
// Server-Push triggern (z.B. durch andere User/Aktion)
await page.evaluate(() => {
// Simuliere Server-Push Event
const event = new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
payload: { liveValue: 'Server Updated' }
})
});
const component = window.LiveComponents.get('integration:test');
component.sse?.dispatchEvent(event);
});
await page.waitForTimeout(500);
// Wert wurde automatisch aktualisiert
const updatedValue = await page.locator('#live-value').textContent();
expect(updatedValue).toBe('Server Updated');
expect(updatedValue).not.toBe(initialValue);
});
test('should handle SSE reconnection on connection loss', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
// Connection simuliert unterbrochen
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.sse?.close();
});
await page.waitForTimeout(500);
// Reconnection Indikator
const reconnecting = await page.locator('.sse-reconnecting').isVisible();
expect(reconnecting).toBe(true);
// Nach Retry-Periode reconnected
await page.waitForTimeout(3000);
const reconnected = await page.locator('.sse-connected').isVisible();
expect(reconnected).toBe(true);
});
test('should support multiple SSE event types', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
const events = [];
await page.exposeFunction('logSSEEvent', (eventType) => {
events.push(eventType);
});
// Verschiedene Event-Typen senden
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
const sse = component.sse;
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'update', payload: {} })
}));
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'notification', payload: {} })
}));
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'sync', payload: {} })
}));
});
await page.waitForTimeout(1000);
// Alle Event-Typen wurden verarbeitet
expect(events).toContain('update');
expect(events).toContain('notification');
expect(events).toContain('sync');
});
test('should batch SSE updates for performance', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
const renderCountBefore = await page.evaluate(() => window.__renderCount || 0);
// Viele schnelle SSE Updates
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
const sse = component.sse;
for (let i = 0; i < 20; i++) {
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
payload: { counter: i }
})
}));
}
});
await page.waitForTimeout(1000);
// Finale Wert korrekt
const finalCounter = await page.locator('#sse-counter').textContent();
expect(parseInt(finalCounter)).toBe(19);
// Deutlich weniger Renders als Updates (Batching)
const renderCountAfter = await page.evaluate(() => window.__renderCount || 0);
const renderDiff = renderCountAfter - renderCountBefore;
expect(renderDiff).toBeLessThan(20);
expect(renderDiff).toBeGreaterThan(0);
});
test('should close SSE connection when component unmounts', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
// Connection aktiv
let connectionState = await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.sse?.readyState;
});
expect(connectionState).toBe(1); // OPEN
// Component unmounten
await page.evaluate(() => {
window.LiveComponents.get('integration:test').unmount();
});
await page.waitForTimeout(500);
// Connection geschlossen
connectionState = await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.sse?.readyState;
});
expect(connectionState).toBe(2); // CLOSED
});
test('should handle SSE authentication and authorization', async ({ page }) => {
// SSE mit Auth Token
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.enableSSE({ authToken: 'valid-token-123' });
});
await page.waitForTimeout(1000);
// Connection erfolgreich mit Auth
const authenticated = await page.locator('.sse-authenticated').isVisible();
expect(authenticated).toBe(true);
// SSE mit ungültigem Token
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.sse?.close();
component.enableSSE({ authToken: 'invalid-token' });
});
await page.waitForTimeout(1000);
// Auth Fehler
const authError = await page.locator('.sse-auth-error').isVisible();
expect(authError).toBe(true);
});
test('should support SSE with custom event filters', async ({ page }) => {
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
// Nur 'important' Events empfangen
component.enableSSE({
filter: (event) => event.priority === 'important'
});
});
await page.waitForTimeout(1000);
const receivedEvents = [];
await page.exposeFunction('trackEvent', (event) => {
receivedEvents.push(event);
});
// Verschiedene Events senden
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
const sse = component.sse;
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
priority: 'important',
payload: { id: 1 }
})
}));
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
priority: 'low',
payload: { id: 2 }
})
}));
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
priority: 'important',
payload: { id: 3 }
})
}));
});
await page.waitForTimeout(1000);
// Nur 'important' Events wurden verarbeitet
expect(receivedEvents.length).toBe(2);
expect(receivedEvents.map(e => e.id)).toEqual([1, 3]);
});
});
test.describe('Integration: Partial Rendering + Batch + SSE', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/integration');
await page.waitForFunction(() => window.LiveComponents !== undefined, { timeout: 5000 });
});
test('should combine partial rendering with batch operations', async ({ page }) => {
const requestsBefore = await page.evaluate(() => window.__requestCount || 0);
// Batch mit Fragment-Updates
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.batch()
.call('updateFragment', { fragmentId: 'fragment-1' })
.call('updateFragment', { fragmentId: 'fragment-2' })
.call('incrementCounter')
.execute();
});
await page.waitForTimeout(500);
// Nur 1 Request für alle Updates
const requestsAfter = await page.evaluate(() => window.__requestCount || 0);
expect(requestsAfter - requestsBefore).toBe(1);
// Beide Fragments aktualisiert
const fragment1Updated = await page.locator('#fragment-1').textContent();
const fragment2Updated = await page.locator('#fragment-2').textContent();
expect(fragment1Updated).toContain('Updated');
expect(fragment2Updated).toContain('Updated');
// Counter auch aktualisiert
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBeGreaterThan(0);
});
test('should push partial updates via SSE', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
// SSE Event mit Fragment Update
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'fragment-update',
fragmentId: 'live-fragment',
html: '<div id="live-fragment">SSE Updated</div>'
})
}));
});
await page.waitForTimeout(500);
// Fragment via SSE aktualisiert
const fragmentContent = await page.locator('#live-fragment').textContent();
expect(fragmentContent).toBe('SSE Updated');
// Kein Full Component Render
const componentTimestamp = await page.locator('#component-timestamp').textContent();
const originalTimestamp = await page.evaluate(() => window.__originalComponentTimestamp);
expect(componentTimestamp).toBe(originalTimestamp);
});
test('should batch SSE-triggered actions efficiently', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
const actionsBefore = await page.evaluate(() => window.__actionCount || 0);
// Mehrere SSE Events die Actions triggern
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
const sse = component.sse;
// 5 Events in kurzer Zeit
for (let i = 0; i < 5; i++) {
sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'trigger-action',
action: 'incrementCounter'
})
}));
}
});
await page.waitForTimeout(1000);
// Actions wurden gebatched
const actionsAfter = await page.evaluate(() => window.__actionCount || 0);
const actionDiff = actionsAfter - actionsBefore;
// Weniger Action-Aufrufe als Events (durch Batching)
expect(actionDiff).toBeLessThan(5);
expect(actionDiff).toBeGreaterThan(0);
// Finaler Counter-Wert korrekt
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBe(5);
});
test('should maintain consistency across all integration features', async ({ page }) => {
await page.click('button#enable-sse');
await page.waitForTimeout(1000);
// Komplexes Szenario: Batch + Partial + SSE gleichzeitig
// 1. Batch mit Fragment-Updates starten
const batchPromise = page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.batch()
.call('updateFragment', { fragmentId: 'fragment-1' })
.call('incrementCounter')
.execute();
});
// 2. Während Batch läuft: SSE Update
await page.waitForTimeout(200);
await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
component.sse.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({
type: 'update',
payload: { liveValue: 'SSE During Batch' }
})
}));
});
await batchPromise;
await page.waitForTimeout(500);
// Alle Updates korrekt angewendet
const fragment1 = await page.locator('#fragment-1').textContent();
expect(fragment1).toContain('Updated');
const counter = await page.locator('#counter-value').textContent();
expect(parseInt(counter)).toBeGreaterThan(0);
const liveValue = await page.locator('#live-value').textContent();
expect(liveValue).toBe('SSE During Batch');
// State konsistent
const stateConsistent = await page.evaluate(() => {
const component = window.LiveComponents.get('integration:test');
return component.validateStateConsistency();
});
expect(stateConsistent).toBe(true);
});
});

View File

@@ -0,0 +1,478 @@
/**
* LiveComponents Performance Benchmarks
*
* Measures and compares performance characteristics of:
* - Fragment-based rendering vs Full HTML rendering
* - Single vs Multiple fragment updates
* - Small vs Large component updates
* - Network payload sizes
* - DOM update times
*
* Run with: npx playwright test performance-benchmarks.spec.js
*
* Generate report: node tests/e2e/livecomponents/generate-performance-report.js
*/
import { test, expect } from '@playwright/test';
// Performance thresholds
const THRESHOLDS = {
fragmentRender: {
small: 50, // ms for single small fragment
medium: 100, // ms for 5-10 fragments
large: 200 // ms for complex component
},
fullRender: {
small: 150, // ms for full render (small component)
medium: 300, // ms for full render (medium component)
large: 500 // ms for full render (large component)
},
networkPayload: {
fragmentMax: 5000, // bytes for fragment response
fullMax: 50000 // bytes for full HTML response
}
};
// Store benchmark results for report generation
const benchmarkResults = [];
/**
* Helper: Measure action execution time
*/
async function measureActionTime(page, componentId, action, params = {}, options = {}) {
const startTime = await page.evaluate(() => performance.now());
await page.evaluate(({ id, actionName, actionParams, opts }) => {
const component = window.LiveComponents.get(id);
return component.call(actionName, actionParams, opts);
}, {
id: componentId,
actionName: action,
actionParams: params,
opts: options
});
// Wait for update to complete
await page.waitForTimeout(50);
const endTime = await page.evaluate(() => performance.now());
return endTime - startTime;
}
/**
* Helper: Measure network payload size
*/
async function measurePayloadSize(page, action) {
let payloadSize = 0;
const responsePromise = page.waitForResponse(
response => response.url().includes('/live-component/'),
{ timeout: 5000 }
);
await action();
const response = await responsePromise;
const body = await response.text();
payloadSize = new TextEncoder().encode(body).length;
return payloadSize;
}
/**
* Helper: Store benchmark result
*/
function storeBenchmarkResult(scenario, metric, value, threshold, unit = 'ms') {
const passed = value <= threshold;
benchmarkResults.push({
scenario,
metric,
value,
threshold,
unit,
passed,
timestamp: new Date().toISOString()
});
}
test.describe('Performance Benchmarks: Fragment vs Full Render', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/performance');
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test('Benchmark: Single small fragment update', async ({ page }) => {
// Measure fragment update
const fragmentTime = await measureActionTime(
page,
'counter:benchmark',
'increment',
{},
{ fragments: ['#counter-value'] }
);
// Measure full render
const fullTime = await measureActionTime(
page,
'counter:benchmark',
'increment',
{}
);
// Store results
storeBenchmarkResult('Single Small Fragment', 'Fragment Update Time', fragmentTime, THRESHOLDS.fragmentRender.small);
storeBenchmarkResult('Single Small Fragment', 'Full Render Time', fullTime, THRESHOLDS.fullRender.small);
// Assertions
expect(fragmentTime).toBeLessThan(THRESHOLDS.fragmentRender.small);
expect(fragmentTime).toBeLessThan(fullTime); // Fragment should be faster
const speedup = ((fullTime - fragmentTime) / fullTime * 100).toFixed(1);
console.log(`Fragment speedup: ${speedup}% faster than full render`);
});
test('Benchmark: Multiple fragment updates (5 fragments)', async ({ page }) => {
const fragments = [
'#item-1',
'#item-2',
'#item-3',
'#item-4',
'#item-5'
];
// Measure fragment update
const fragmentTime = await measureActionTime(
page,
'list:benchmark',
'updateItems',
{ count: 5 },
{ fragments }
);
// Measure full render
const fullTime = await measureActionTime(
page,
'list:benchmark',
'updateItems',
{ count: 5 }
);
// Store results
storeBenchmarkResult('Multiple Fragments (5)', 'Fragment Update Time', fragmentTime, THRESHOLDS.fragmentRender.medium);
storeBenchmarkResult('Multiple Fragments (5)', 'Full Render Time', fullTime, THRESHOLDS.fullRender.medium);
// Assertions
expect(fragmentTime).toBeLessThan(THRESHOLDS.fragmentRender.medium);
expect(fragmentTime).toBeLessThan(fullTime);
const speedup = ((fullTime - fragmentTime) / fullTime * 100).toFixed(1);
console.log(`5-fragment speedup: ${speedup}% faster than full render`);
});
test('Benchmark: Large component update (100 items)', async ({ page }) => {
const fragments = ['#item-list'];
// Measure fragment update
const fragmentTime = await measureActionTime(
page,
'product-list:benchmark',
'loadItems',
{ count: 100 },
{ fragments }
);
// Measure full render
const fullTime = await measureActionTime(
page,
'product-list:benchmark',
'loadItems',
{ count: 100 }
);
// Store results
storeBenchmarkResult('Large Component (100 items)', 'Fragment Update Time', fragmentTime, THRESHOLDS.fragmentRender.large);
storeBenchmarkResult('Large Component (100 items)', 'Full Render Time', fullTime, THRESHOLDS.fullRender.large);
// Assertions
expect(fragmentTime).toBeLessThan(THRESHOLDS.fragmentRender.large);
expect(fragmentTime).toBeLessThan(fullTime);
const speedup = ((fullTime - fragmentTime) / fullTime * 100).toFixed(1);
console.log(`100-item speedup: ${speedup}% faster than full render`);
});
test('Benchmark: Network payload size comparison', async ({ page }) => {
// Measure fragment payload
const fragmentPayload = await measurePayloadSize(page, async () => {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment', {}, {
fragments: ['#counter-value']
});
});
});
// Reset state
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('reset');
});
await page.waitForTimeout(100);
// Measure full HTML payload
const fullPayload = await measurePayloadSize(page, async () => {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment');
});
});
// Store results
storeBenchmarkResult('Network Payload', 'Fragment Payload Size', fragmentPayload, THRESHOLDS.networkPayload.fragmentMax, 'bytes');
storeBenchmarkResult('Network Payload', 'Full HTML Payload Size', fullPayload, THRESHOLDS.networkPayload.fullMax, 'bytes');
// Assertions
expect(fragmentPayload).toBeLessThan(THRESHOLDS.networkPayload.fragmentMax);
expect(fullPayload).toBeLessThan(THRESHOLDS.networkPayload.fullMax);
expect(fragmentPayload).toBeLessThan(fullPayload);
const reduction = ((fullPayload - fragmentPayload) / fullPayload * 100).toFixed(1);
console.log(`Fragment payload reduction: ${reduction}% smaller than full HTML`);
console.log(`Fragment: ${fragmentPayload} bytes, Full: ${fullPayload} bytes`);
});
test('Benchmark: Rapid successive updates (10 updates)', async ({ page }) => {
const updateCount = 10;
// Measure fragment updates
const fragmentStartTime = await page.evaluate(() => performance.now());
for (let i = 0; i < updateCount; i++) {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment', {}, {
fragments: ['#counter-value']
});
});
await page.waitForTimeout(10); // Small delay between updates
}
const fragmentEndTime = await page.evaluate(() => performance.now());
const fragmentTotalTime = fragmentEndTime - fragmentStartTime;
// Reset
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('reset');
});
await page.waitForTimeout(100);
// Measure full renders
const fullStartTime = await page.evaluate(() => performance.now());
for (let i = 0; i < updateCount; i++) {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment');
});
await page.waitForTimeout(10);
}
const fullEndTime = await page.evaluate(() => performance.now());
const fullTotalTime = fullEndTime - fullStartTime;
// Store results
storeBenchmarkResult('Rapid Updates (10x)', 'Fragment Total Time', fragmentTotalTime, 500);
storeBenchmarkResult('Rapid Updates (10x)', 'Full Render Total Time', fullTotalTime, 1500);
// Assertions
expect(fragmentTotalTime).toBeLessThan(fullTotalTime);
const avgFragment = fragmentTotalTime / updateCount;
const avgFull = fullTotalTime / updateCount;
console.log(`Average fragment update: ${avgFragment.toFixed(2)}ms`);
console.log(`Average full render: ${avgFull.toFixed(2)}ms`);
console.log(`Fragment ${((fullTotalTime / fragmentTotalTime).toFixed(1))}x faster for rapid updates`);
});
test('Benchmark: DOM manipulation overhead', async ({ page }) => {
// Measure pure DOM update time (client-side only)
const domUpdateTime = await page.evaluate(() => {
const container = document.querySelector('[data-component-id="counter:benchmark"]');
const fragment = container.querySelector('[data-lc-fragment="counter-value"]');
const startTime = performance.now();
// Simulate fragment update
fragment.textContent = 'Updated Value';
const endTime = performance.now();
return endTime - startTime;
});
// Measure network + server time for fragment update
const startTime = await page.evaluate(() => performance.now());
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment', {}, {
fragments: ['#counter-value']
});
});
await page.waitForTimeout(50);
const endTime = await page.evaluate(() => performance.now());
const totalTime = endTime - startTime;
const networkServerTime = totalTime - domUpdateTime;
// Store results
storeBenchmarkResult('DOM Overhead', 'Pure DOM Update', domUpdateTime, 5);
storeBenchmarkResult('DOM Overhead', 'Network + Server Time', networkServerTime, 100);
storeBenchmarkResult('DOM Overhead', 'Total Fragment Update', totalTime, 150);
console.log(`DOM update: ${domUpdateTime.toFixed(2)}ms`);
console.log(`Network + Server: ${networkServerTime.toFixed(2)}ms`);
console.log(`Total: ${totalTime.toFixed(2)}ms`);
expect(domUpdateTime).toBeLessThan(5); // DOM should be very fast
});
test('Benchmark: Memory consumption comparison', async ({ page }) => {
// Measure memory before
const memoryBefore = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
// Perform 50 fragment updates
for (let i = 0; i < 50; i++) {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment', {}, {
fragments: ['#counter-value']
});
});
await page.waitForTimeout(10);
}
// Measure memory after
const memoryAfterFragments = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
// Reset
await page.reload();
await page.waitForFunction(() => window.LiveComponents !== undefined);
const memoryBeforeFull = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
// Perform 50 full renders
for (let i = 0; i < 50; i++) {
await page.evaluate(() => {
window.LiveComponents.get('counter:benchmark').call('increment');
});
await page.waitForTimeout(10);
}
const memoryAfterFull = await page.evaluate(() => {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return 0;
});
if (memoryBefore > 0) {
const fragmentMemoryDelta = memoryAfterFragments - memoryBefore;
const fullMemoryDelta = memoryAfterFull - memoryBeforeFull;
storeBenchmarkResult('Memory Consumption (50 updates)', 'Fragment Updates Memory Delta', fragmentMemoryDelta, 1000000, 'bytes');
storeBenchmarkResult('Memory Consumption (50 updates)', 'Full Renders Memory Delta', fullMemoryDelta, 2000000, 'bytes');
console.log(`Fragment memory delta: ${(fragmentMemoryDelta / 1024).toFixed(2)} KB`);
console.log(`Full render memory delta: ${(fullMemoryDelta / 1024).toFixed(2)} KB`);
} else {
console.log('Memory API not available (Firefox/Safari)');
}
});
test('Benchmark: Cache effectiveness', async ({ page }) => {
// First update (no cache)
const firstUpdateTime = await measureActionTime(
page,
'counter:benchmark',
'increment',
{},
{ fragments: ['#counter-value'] }
);
// Second update (potentially cached)
const secondUpdateTime = await measureActionTime(
page,
'counter:benchmark',
'increment',
{},
{ fragments: ['#counter-value'] }
);
// Third update (cached)
const thirdUpdateTime = await measureActionTime(
page,
'counter:benchmark',
'increment',
{},
{ fragments: ['#counter-value'] }
);
const avgCachedTime = (secondUpdateTime + thirdUpdateTime) / 2;
storeBenchmarkResult('Cache Effectiveness', 'First Update (Cold)', firstUpdateTime, 100);
storeBenchmarkResult('Cache Effectiveness', 'Average Cached Update', avgCachedTime, 80);
console.log(`First update (cold): ${firstUpdateTime.toFixed(2)}ms`);
console.log(`Average cached: ${avgCachedTime.toFixed(2)}ms`);
if (avgCachedTime < firstUpdateTime) {
const improvement = ((firstUpdateTime - avgCachedTime) / firstUpdateTime * 100).toFixed(1);
console.log(`Cache improves performance by ${improvement}%`);
}
});
// After all tests, write results to file for report generation
test.afterAll(async () => {
const fs = await import('fs');
const path = await import('path');
const resultsFile = path.default.join(
process.cwd(),
'test-results',
'benchmark-results.json'
);
// Ensure directory exists
const dir = path.default.dirname(resultsFile);
if (!fs.default.existsSync(dir)) {
fs.default.mkdirSync(dir, { recursive: true });
}
fs.default.writeFileSync(
resultsFile,
JSON.stringify({
timestamp: new Date().toISOString(),
results: benchmarkResults,
summary: {
total: benchmarkResults.length,
passed: benchmarkResults.filter(r => r.passed).length,
failed: benchmarkResults.filter(r => !r.passed).length
}
}, null, 2)
);
console.log(`\nBenchmark results written to: ${resultsFile}`);
console.log(`Total benchmarks: ${benchmarkResults.length}`);
console.log(`Passed: ${benchmarkResults.filter(r => r.passed).length}`);
console.log(`Failed: ${benchmarkResults.filter(r => !r.passed).length}`);
});
});

View File

@@ -0,0 +1,678 @@
/**
* LiveComponents Security E2E Tests
*
* Tests security features and protections:
* - CSRF token validation for state-changing actions
* - Rate limiting for action calls
* - Idempotency for critical operations
* - Input sanitization and XSS prevention
* - Authorization checks for protected actions
* - Session security and token rotation
*
* Run with: npx playwright test security.spec.js
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents Security Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/security');
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test.describe('CSRF Protection', () => {
test('should include CSRF token in action requests', async ({ page }) => {
// Monitor network requests
const actionRequests = [];
page.on('request', request => {
if (request.url().includes('/live-component/')) {
actionRequests.push({
url: request.url(),
headers: request.headers(),
postData: request.postData()
});
}
});
// Trigger action
await page.click('button#trigger-action');
// Wait for request to complete
await page.waitForTimeout(1000);
// Verify at least one action request was made
expect(actionRequests.length).toBeGreaterThan(0);
// Verify CSRF token present in request
const actionRequest = actionRequests[0];
// Check for CSRF token in headers or POST data
const hasCsrfHeader = 'x-csrf-token' in actionRequest.headers ||
'x-xsrf-token' in actionRequest.headers;
const hasCsrfInBody = actionRequest.postData &&
actionRequest.postData.includes('_csrf_token');
expect(hasCsrfHeader || hasCsrfInBody).toBe(true);
});
test('should reject action without CSRF token', async ({ page }) => {
// Intercept and remove CSRF token
await page.route('**/live-component/**', async (route) => {
const request = route.request();
// Remove CSRF token from headers
const headers = { ...request.headers() };
delete headers['x-csrf-token'];
delete headers['x-xsrf-token'];
// Remove from POST data if present
let postData = request.postData();
if (postData) {
postData = postData.replace(/_csrf_token=[^&]+&?/, '');
}
await route.continue({
headers,
postData
});
});
// Trigger action
await page.click('button#trigger-action');
// Wait for error
await page.waitForTimeout(1000);
// Verify error response
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('CSRF');
});
test('should reject action with invalid CSRF token', async ({ page }) => {
// Intercept and replace CSRF token with invalid one
await page.route('**/live-component/**', async (route) => {
const request = route.request();
const headers = {
...request.headers(),
'x-csrf-token': 'invalid-token-12345'
};
await route.continue({ headers });
});
// Trigger action
await page.click('button#trigger-action');
// Wait for error
await page.waitForTimeout(1000);
// Verify error response
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('CSRF');
});
test('should rotate CSRF token after usage', async ({ page }) => {
// Get initial CSRF token
const initialToken = await page.evaluate(() => {
return document.querySelector('meta[name="csrf-token"]')?.content;
});
expect(initialToken).toBeTruthy();
// Trigger action
await page.click('button#trigger-action');
// Wait for action to complete
await page.waitForTimeout(1000);
// Get new CSRF token
const newToken = await page.evaluate(() => {
return document.querySelector('meta[name="csrf-token"]')?.content;
});
// Tokens should be different after action
// (depending on framework configuration)
// For now, just verify new token exists
expect(newToken).toBeTruthy();
});
});
test.describe('Rate Limiting', () => {
test('should enforce rate limit on rapid action calls', async ({ page }) => {
// Trigger action rapidly 20 times
const results = [];
for (let i = 0; i < 20; i++) {
try {
await page.click('button#trigger-action');
await page.waitForTimeout(50); // Small delay
const success = await page.locator('.action-success').isVisible();
results.push({ success, attempt: i + 1 });
} catch (error) {
results.push({ success: false, attempt: i + 1, error: error.message });
}
}
// Some requests should be rate limited
const successCount = results.filter(r => r.success).length;
// Expect rate limiting to kick in (not all 20 succeed)
expect(successCount).toBeLessThan(20);
// Verify rate limit error message
const rateLimitError = await page.locator('.rate-limit-error').isVisible();
expect(rateLimitError).toBe(true);
});
test('should provide retry-after information when rate limited', async ({ page }) => {
// Trigger rapid requests to hit rate limit
for (let i = 0; i < 15; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// Wait for rate limit error
await page.waitForSelector('.rate-limit-error', { timeout: 5000 });
// Verify retry-after information
const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after');
expect(retryAfter).toBeTruthy();
expect(parseInt(retryAfter)).toBeGreaterThan(0);
console.log(`Rate limit retry-after: ${retryAfter} seconds`);
});
test('should reset rate limit after cooldown period', async ({ page }) => {
// Hit rate limit
for (let i = 0; i < 15; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// Verify rate limited
await expect(page.locator('.rate-limit-error')).toBeVisible();
// Get retry-after duration
const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after');
const waitTime = parseInt(retryAfter) * 1000 + 1000; // Add 1 second buffer
console.log(`Waiting ${waitTime}ms for rate limit reset...`);
// Wait for cooldown
await page.waitForTimeout(waitTime);
// Try action again
await page.click('button#trigger-action');
// Should succeed now
await expect(page.locator('.action-success')).toBeVisible();
});
test('should have separate rate limits per action type', async ({ page }) => {
// Hit rate limit on action A
for (let i = 0; i < 15; i++) {
await page.click('button#action-a');
await page.waitForTimeout(20);
}
// Verify action A is rate limited
await expect(page.locator('.rate-limit-error-a')).toBeVisible();
// Action B should still work
await page.click('button#action-b');
await expect(page.locator('.action-success-b')).toBeVisible();
});
test('should apply IP-based rate limiting', async ({ page, context }) => {
// Create multiple browser contexts (simulate multiple users from same IP)
const context2 = await page.context().browser().newContext();
const page2 = await context2.newPage();
await page2.goto('https://localhost/livecomponents/test/security');
await page2.waitForFunction(() => window.LiveComponents !== undefined);
// User 1: Trigger many requests
for (let i = 0; i < 10; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// User 2: Should also be affected by IP-based limit
for (let i = 0; i < 10; i++) {
await page2.click('button#trigger-action');
await page2.waitForTimeout(20);
}
// Both should eventually be rate limited (shared IP limit)
const user1Limited = await page.locator('.rate-limit-error').isVisible();
const user2Limited = await page2.locator('.rate-limit-error').isVisible();
expect(user1Limited || user2Limited).toBe(true);
await context2.close();
});
});
test.describe('Idempotency', () => {
test('should handle duplicate action calls idempotently', async ({ page }) => {
// Get initial state
const initialValue = await page.locator('#counter-value').textContent();
const initialCount = parseInt(initialValue);
// Trigger increment action twice with same idempotency key
const idempotencyKey = 'test-key-12345';
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Same idempotency key - should be ignored
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Value should only increment once
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(initialCount + 1);
});
test('should allow different idempotency keys', async ({ page }) => {
const initialValue = await page.locator('#counter-value').textContent();
const initialCount = parseInt(initialValue);
// First action with idempotency key 1
await page.evaluate(() => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: 'key-1'
});
});
await page.waitForTimeout(500);
// Second action with idempotency key 2 (different key)
await page.evaluate(() => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: 'key-2'
});
});
await page.waitForTimeout(500);
// Both should execute (different keys)
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(initialCount + 2);
});
test('should expire idempotency keys after TTL', async ({ page }) => {
const idempotencyKey = 'expiring-key';
// First action
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
const afterFirstValue = await page.locator('#counter-value').textContent();
const afterFirstCount = parseInt(afterFirstValue);
// Wait for idempotency key to expire (assume 5 second TTL)
await page.waitForTimeout(6000);
// Same key should work again after expiration
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(afterFirstCount + 1);
});
test('should return cached result for duplicate idempotent action', async ({ page }) => {
const idempotencyKey = 'cached-result-key';
// First action
const result1 = await page.evaluate((key) => {
return window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Duplicate action (same key)
const result2 = await page.evaluate((key) => {
return window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
// Results should be identical (cached)
expect(result2).toEqual(result1);
});
});
test.describe('Input Sanitization & XSS Prevention', () => {
test('should sanitize HTML in action parameters', async ({ page }) => {
// Attempt XSS injection via action parameter
const xssPayload = '<script>alert("XSS")</script>';
await page.evaluate((payload) => {
window.LiveComponents.get('text:test').call('setText', {
text: payload
});
}, xssPayload);
await page.waitForTimeout(500);
// Verify script tag is sanitized/escaped
const displayedText = await page.locator('#text-display').textContent();
// Should display as text, not execute
expect(displayedText).toContain('script');
expect(displayedText).not.toContain('<script>');
// Verify no script execution
const alertShown = await page.evaluate(() => {
return window.__alertCalled === true;
});
expect(alertShown).toBeFalsy();
});
test('should escape HTML entities in rendered content', async ({ page }) => {
const maliciousInput = '<img src=x onerror=alert("XSS")>';
await page.evaluate((input) => {
window.LiveComponents.get('text:test').call('setText', {
text: input
});
}, maliciousInput);
await page.waitForTimeout(500);
// Check that img tag is escaped
const innerHTML = await page.locator('#text-display').innerHTML();
// Should be escaped HTML entities
expect(innerHTML).toContain('&lt;img');
expect(innerHTML).not.toContain('<img');
});
test('should prevent JavaScript injection in fragment updates', async ({ page }) => {
const jsPayload = 'javascript:alert("XSS")';
await page.evaluate((payload) => {
window.LiveComponents.get('link:test').call('setLink', {
url: payload
});
}, jsPayload);
await page.waitForTimeout(500);
// Verify link is sanitized
const linkHref = await page.locator('#link-display').getAttribute('href');
// Should not contain javascript: protocol
expect(linkHref).not.toContain('javascript:');
});
test('should validate and sanitize file paths', async ({ page }) => {
// Path traversal attempt
const maliciousPath = '../../../etc/passwd';
await page.evaluate((path) => {
window.LiveComponents.get('file:test').call('loadFile', {
path: path
});
}, maliciousPath);
await page.waitForTimeout(500);
// Should reject or sanitize path
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('Invalid path');
});
test('should prevent SQL injection in search parameters', async ({ page }) => {
const sqlInjection = "'; DROP TABLE users; --";
await page.evaluate((query) => {
window.LiveComponents.get('search:test').call('search', {
query: query
});
}, sqlInjection);
await page.waitForTimeout(500);
// Search should handle safely (parameterized queries)
// No error should occur, but SQL shouldn't execute
const results = await page.locator('.search-results').isVisible();
expect(results).toBe(true); // Search completed without error
});
});
test.describe('Authorization', () => {
test('should reject unauthorized action calls', async ({ page }) => {
// Attempt to call admin-only action without auth
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('deleteAllData', {});
});
await page.waitForTimeout(500);
// Should be rejected
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Unauthorized');
});
test('should allow authorized action calls', async ({ page }) => {
// Login first
await page.fill('#username', 'admin');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Now try admin action
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('performAdminAction', {});
});
await page.waitForTimeout(500);
// Should succeed
const successMessage = await page.locator('.action-success').isVisible();
expect(successMessage).toBe(true);
});
test('should respect role-based authorization', async ({ page }) => {
// Login as regular user
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Try admin action - should fail
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('performAdminAction', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Insufficient permissions');
});
});
test.describe('Session Security', () => {
test('should invalidate session after logout', async ({ page }) => {
// Login
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Perform authenticated action
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const beforeLogout = await page.locator('.user-data').isVisible();
expect(beforeLogout).toBe(true);
// Logout
await page.click('#logout-btn');
await page.waitForSelector('.logged-out-indicator');
// Try same action - should fail
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Unauthorized');
});
test('should detect session hijacking attempts', async ({ page, context }) => {
// Login in first tab
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Get session cookie
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'session_id');
expect(sessionCookie).toBeTruthy();
// Create new context with different IP/user agent
const newContext = await page.context().browser().newContext({
userAgent: 'Mozilla/5.0 (Different Browser)'
});
const newPage = await newContext.newPage();
// Set stolen cookie
await newContext.addCookies([sessionCookie]);
await newPage.goto('https://localhost/livecomponents/test/security');
// Try to use session from different context
await newPage.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await newPage.waitForTimeout(500);
// Should detect session hijacking and reject
const errorMessage = await newPage.locator('.security-error').textContent();
expect(errorMessage).toContain('Session invalid');
await newContext.close();
});
test('should enforce session timeout', async ({ page }) => {
// Login
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Wait for session timeout (assume 5 minute timeout in test environment)
console.log('Waiting for session timeout (5 minutes)...');
await page.waitForTimeout(5 * 60 * 1000 + 5000); // 5 min + buffer
// Try action - should fail due to timeout
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.session-expired-error').textContent();
expect(errorMessage).toContain('Session expired');
});
});
test.describe('Content Security Policy', () => {
test('should have CSP headers set', async ({ page }) => {
const response = await page.goto('https://localhost/livecomponents/test/security');
const cspHeader = response.headers()['content-security-policy'];
expect(cspHeader).toBeTruthy();
console.log('CSP Header:', cspHeader);
});
test('should block inline scripts via CSP', async ({ page }) => {
// Attempt to inject inline script
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'alert("Inline script executed")';
document.body.appendChild(script);
});
await page.waitForTimeout(500);
// CSP should block inline script
const hasCspError = consoleErrors.some(err =>
err.includes('Content Security Policy') || err.includes('CSP')
);
expect(hasCspError).toBe(true);
});
});
});