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

490
tests/e2e/README.md Normal file
View File

@@ -0,0 +1,490 @@
# E2E Tests with Playwright
End-to-end testing infrastructure for the Custom PHP Framework using Playwright.
## Overview
Comprehensive browser-based E2E tests for:
- **Critical User Journeys** - Homepage, authentication, core workflows
- **LiveComponents Functionality** - Real-time updates, form validation, fragment rendering
- **Integration Scenarios** - Cross-browser compatibility, performance, security
## Test Coverage
### Critical Paths (`critical-paths/`)
#### Homepage Tests (`critical-paths/homepage.spec.ts`)
- Page load and accessibility
- Valid HTML structure and meta tags
- Navigation functionality
- JavaScript error detection
- Security headers verification
- Responsive design (mobile viewport)
- ARIA landmarks and accessibility
- CSS and asset loading
### LiveComponents Tests (`live-components/`)
#### Form Validation (`live-components/form-validation.spec.ts`)
- Real-time validation error display
- Email format validation
- Validation error clearing
- Submit prevention with errors
- Required field handling
- Max length validation
- Character counter functionality
#### Real-time Updates (`live-components/real-time-updates.spec.ts`)
- SSE (Server-Sent Events) connections
- HTMX request handling
- DOM updates without page reload
- Loading states during updates
- Form data preservation during partial updates
- WebSocket connections (if present)
- Connection error handling
### Legacy LiveComponents Tests
#### Fragment Rendering Tests (`livecomponents/fragment-rendering.spec.js`)
**Basic Functionality**:
- Single fragment patching without full re-render
- Multiple fragment updates simultaneously
- Nested fragment updates
- Fallback to full render when fragments not specified
**State Preservation**:
- Focus state preservation during fragment updates
- Scroll position preservation
- Selection range preservation in text inputs
- Event listener preservation on updated elements
**Performance**:
- Fragment rendering speed vs full HTML rendering
- Network payload size reduction (fragment vs full)
- Batch update optimization
- Rapid successive updates handling
**Edge Cases**:
- Empty fragments
- Very large fragments (1000+ items)
- Special characters in fragment content
- Whitespace and formatting changes
- Data attribute updates
- Missing fragment error handling
## Prerequisites
### System Dependencies
Playwright requires certain system libraries. Install them with:
```bash
# Option 1: Using Playwright's installer (recommended)
sudo npx playwright install-deps
# Option 2: Using apt directly
sudo apt-get install \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libxkbcommon0 \
libatspi2.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libcairo2 \
libpango-1.0-0 \
libasound2
```
### Node.js Dependencies
Install Playwright and browsers:
```bash
# Install Playwright package (already done via package.json)
npm install
# Install browser binaries
npx playwright install chromium # Chromium only
npx playwright install # All browsers (Chromium, Firefox, WebKit)
```
### Development Server
E2E tests require the local development server to be running:
```bash
# Start Docker containers and development server
make up
# Verify server is accessible
curl -k https://localhost
```
## Running Tests
### All E2E Tests
```bash
# Run all E2E tests headless
npm run test:e2e
# Run with UI mode (interactive)
npm run test:e2e:ui
# Run with headed browsers (see browser windows)
npm run test:e2e:headed
# Run with debug mode (step through tests)
npm run test:e2e:debug
```
### Specific Test Suites
```bash
# Critical path tests only
npm run test:e2e:critical
# LiveComponents tests only
npm run test:e2e:livecomponents
# Specific test files
npm run test:e2e:homepage
npm run test:e2e:validation
npm run test:e2e:realtime
# Legacy fragment tests
npx playwright test livecomponents/fragment-rendering.spec.js
```
### Filtering Tests
```bash
# Run specific test by title
npx playwright test --grep "should patch single fragment"
# Run tests in specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox
npx playwright test --project=webkit
```
### Test Output and Reports
```bash
# Generate HTML report (automatically opens in browser)
npx playwright show-report
# View last test run results
npx playwright show-report playwright-report
```
## Configuration
### Playwright Config (`playwright.config.js`)
**Key Settings**:
- **Base URL**: `https://localhost` (local development server)
- **Ignore HTTPS Errors**: Enabled for local SSL certificates
- **User-Agent**: Set to avoid firewall blocking
- **Timeout**: 30 seconds per test
- **Retries**: 2 retries in CI, 0 locally
- **Workers**: Parallel execution (1 in CI, auto locally)
**Projects Configured**:
- Chromium (Desktop)
- Firefox (Desktop)
- WebKit/Safari (Desktop)
- Mobile Chrome (Pixel 5)
- Mobile Safari (iPhone 12)
**Reporters**:
- HTML report: `playwright-report/index.html`
- List (console output)
- JSON: `playwright-report/results.json`
### Test Environment
Tests assume the following URLs are accessible:
```
https://localhost/livecomponents/test/counter
https://localhost/livecomponents/test/shopping-cart
https://localhost/livecomponents/test/form
https://localhost/livecomponents/test/nested-fragments
https://localhost/livecomponents/test/performance
https://localhost/livecomponents/test/long-list
https://localhost/livecomponents/test/data-attributes
https://localhost/livecomponents/test/event-listeners
https://localhost/livecomponents/test/large-fragment
https://localhost/livecomponents/test/special-chars
```
**Note**: These test pages may need to be created in the application.
## Writing New E2E Tests
### Test Structure
```javascript
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test.beforeEach(async ({ page }) => {
// Navigate to test page
await page.goto('https://localhost/path/to/test-page');
// Wait for LiveComponents to initialize
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test('should do something', async ({ page }) => {
// Arrange: Set up test state
const component = page.locator('[data-component-id="component:id"]');
// Act: Perform actions
await page.click('[data-action="someAction"]');
await page.waitForTimeout(100); // Wait for fragment update
// Assert: Verify results
const result = await page.textContent('[data-lc-fragment="result"]');
expect(result).toContain('Expected Value');
});
});
```
### Best Practices
**1. Use Data Attributes for Selectors**:
```javascript
// ✅ Good: Stable selectors
await page.click('[data-action="increment"]');
await page.locator('[data-lc-fragment="counter"]');
// ❌ Bad: Brittle selectors
await page.click('button.btn-primary');
await page.locator('.counter-display');
```
**2. Wait for LiveComponents Initialization**:
```javascript
// Always wait for LiveComponents to be ready
await page.waitForFunction(() => window.LiveComponents !== undefined);
```
**3. Handle Async Updates**:
```javascript
// Wait for network request to complete
await page.waitForResponse(response =>
response.url().includes('/live-component/') && response.status() === 200
);
// Or use small timeout for fragment updates
await page.waitForTimeout(100);
```
**4. Use Page Object Pattern for Complex Pages**:
```javascript
class ShoppingCartPage {
constructor(page) {
this.page = page;
this.addItemButton = page.locator('[data-action="addItem"]');
this.cartItems = page.locator('[data-lc-fragment="cart-items"] .cart-item');
this.cartTotal = page.locator('[data-lc-fragment="cart-total"]');
}
async addItem(item) {
await this.addItemButton.click();
await this.page.waitForTimeout(100);
}
async getItemCount() {
return await this.cartItems.count();
}
}
```
**5. Test Cross-Browser Compatibility**:
```javascript
// Use browser-agnostic APIs
const userAgent = await page.evaluate(() => navigator.userAgent);
// Avoid browser-specific features unless testing for them
```
## Debugging Tests
### Visual Debugging
```bash
# Run with UI mode (recommended)
npm run test:e2e:ui
# Run with headed browsers
npm run test:e2e:headed
# Debug specific test
npm run test:e2e:debug -- --grep "test name"
```
### Screenshots and Videos
Playwright automatically captures:
- **Screenshots**: On test failure
- **Videos**: Retained on failure
- **Traces**: On first retry
View artifacts in `test-results/` directory.
### Console Logs
```javascript
// Listen to console messages
page.on('console', msg => console.log('Browser log:', msg.text()));
// Listen to page errors
page.on('pageerror', err => console.error('Page error:', err));
```
### Playwright Inspector
```bash
# Step through test with debugger
npx playwright test --debug
# Pause test at specific point
await page.pause(); // Add this in test code
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
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 browsers
run: npx playwright install --with-deps chromium
- name: Start development server
run: make up
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
```
## Troubleshooting
### Common Issues
**1. Server not accessible**:
```bash
# Verify Docker containers are running
docker ps
# Check server is responding
curl -k https://localhost
# Check firewall/SSL settings
```
**2. Tests timing out**:
```javascript
// Increase timeout for specific test
test('slow operation', async ({ page }) => {
test.setTimeout(60000); // 60 seconds
// ...
});
```
**3. Flaky tests**:
```javascript
// Use waitForSelector with state
await page.waitForSelector('[data-lc-fragment="result"]', {
state: 'visible',
timeout: 5000
});
// Or wait for network to be idle
await page.waitForLoadState('networkidle');
```
**4. Browser launch errors**:
```bash
# Install missing dependencies
sudo npx playwright install-deps
# Use specific browser
npx playwright test --project=chromium
```
### Performance Issues
**Slow tests**:
- Run fewer browsers: `npx playwright test --project=chromium`
- Disable parallel execution: `npx playwright test --workers=1`
- Reduce retries: Set `retries: 0` in config
**High memory usage**:
- Close browsers between test files
- Limit workers: `--workers=2`
- Use headless mode (default)
## Test Maintenance
### Keeping Tests Updated
1. **Update test pages** when components change
2. **Update selectors** when HTML structure changes
3. **Add new tests** for new features
4. **Remove obsolete tests** for removed features
5. **Review flaky tests** regularly
### Test Quality Metrics
Track these metrics for test health:
- **Pass rate**: Should be >95%
- **Flakiness**: Retry rate should be <5%
- **Duration**: Average test duration <30s
- **Coverage**: All critical user flows tested
## Resources
- [Playwright Documentation](https://playwright.dev)
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
- [Best Practices](https://playwright.dev/docs/best-practices)
- [Debugging Guide](https://playwright.dev/docs/debug)
## Support
For issues or questions about E2E tests:
1. Check this README
2. Review existing tests for examples
3. Check Playwright documentation
4. Ask in team chat or create GitHub issue

View File

@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
/**
* Critical Path: Homepage Accessibility and Basic Functionality
*
* Tests the most critical user journey - accessing the homepage
*/
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should load homepage successfully', async ({ page }) => {
// Check page loaded
await expect(page).toHaveTitle(/michaelschiemer/i);
// Check main content is visible
await expect(page.locator('main')).toBeVisible();
});
test('should have valid HTML structure', async ({ page }) => {
// Check essential meta tags
const metaViewport = page.locator('meta[name="viewport"]');
await expect(metaViewport).toHaveAttribute('content', /width=device-width/);
// Check for proper heading hierarchy
const h1 = page.locator('h1').first();
await expect(h1).toBeVisible();
});
test('should have working navigation', async ({ page }) => {
// Check navigation is present
const nav = page.locator('nav').first();
await expect(nav).toBeVisible();
// Check navigation links are clickable
const navLinks = page.locator('nav a');
const linkCount = await navLinks.count();
expect(linkCount).toBeGreaterThan(0);
// First link should be clickable
await expect(navLinks.first()).toBeVisible();
});
test('should load without JavaScript errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => {
errors.push(error.message);
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Assert no JavaScript errors
expect(errors).toHaveLength(0);
});
test('should have proper security headers', async ({ page }) => {
const response = await page.goto('/');
// Check for security headers
const headers = response?.headers();
expect(headers).toBeDefined();
if (headers) {
// X-Frame-Options
expect(headers['x-frame-options']).toBeDefined();
// X-Content-Type-Options
expect(headers['x-content-type-options']).toBe('nosniff');
// Content-Security-Policy (if configured)
// expect(headers['content-security-policy']).toBeDefined();
}
});
test('should be responsive (mobile viewport)', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// Check mobile menu is accessible
const mobileMenu = page.locator('[data-mobile-menu], .mobile-menu, button[aria-label*="menu"]');
// Mobile menu should either be visible or exist in DOM
const count = await mobileMenu.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have accessible landmarks', async ({ page }) => {
// Check for ARIA landmarks
await expect(page.locator('[role="main"], main')).toBeVisible();
await expect(page.locator('[role="navigation"], nav')).toBeVisible();
// Footer should exist
const footer = page.locator('[role="contentinfo"], footer');
await expect(footer).toBeVisible();
});
test('should load CSS and assets', async ({ page }) => {
const response = await page.goto('/');
// Check status code
expect(response?.status()).toBe(200);
// Wait for all network requests to complete
await page.waitForLoadState('networkidle');
// Check if stylesheets loaded
const stylesheets = page.locator('link[rel="stylesheet"]');
const count = await stylesheets.count();
expect(count).toBeGreaterThan(0);
// Verify at least one stylesheet is loaded
const firstStylesheet = stylesheets.first();
await expect(firstStylesheet).toHaveAttribute('href', /.+/);
});
});

View File

@@ -0,0 +1,189 @@
import { test, expect } from '@playwright/test';
import {
waitForLiveComponent,
fillAndSubmitForm,
assertValidationError,
assertNoValidationErrors,
waitForSuccessMessage
} from '../support/test-helpers';
/**
* LiveComponents: Form Validation Tests
*
* Tests real-time form validation with LiveComponents
*/
test.describe('LiveComponents Form Validation', () => {
test.beforeEach(async ({ page }) => {
// Navigate to a page with LiveComponent form
// Adjust URL based on your actual routes
await page.goto('/');
});
test('should show validation errors on invalid input', async ({ page }) => {
// Skip if no form is present on homepage
const formCount = await page.locator('form').count();
if (formCount === 0) {
test.skip();
return;
}
const form = page.locator('form').first();
// Try to submit empty form
await form.locator('button[type="submit"]').click();
// Wait a bit for validation to trigger
await page.waitForTimeout(500);
// Check if any validation messages appear
const errorMessages = page.locator('.error-message, [class*="error-"], .invalid-feedback');
const errorCount = await errorMessages.count();
// If there are required fields, there should be error messages
const requiredFields = await form.locator('[required]').count();
if (requiredFields > 0) {
expect(errorCount).toBeGreaterThan(0);
}
});
test('should validate email format in real-time', async ({ page }) => {
// Look for email input
const emailInput = page.locator('input[type="email"]').first();
const emailCount = await emailInput.count();
if (emailCount === 0) {
test.skip();
return;
}
// Enter invalid email
await emailInput.fill('invalid-email');
await emailInput.blur();
// Wait for validation
await page.waitForTimeout(500);
// Check for error message (if client-side validation is present)
// Note: HTML5 validation or custom validation might trigger
const validity = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valid);
expect(validity).toBe(false);
});
test('should clear validation errors on valid input', async ({ page }) => {
const emailInput = page.locator('input[type="email"]').first();
const emailCount = await emailInput.count();
if (emailCount === 0) {
test.skip();
return;
}
// Enter invalid email first
await emailInput.fill('invalid');
await emailInput.blur();
await page.waitForTimeout(300);
// Now enter valid email
await emailInput.fill('valid@example.com');
await emailInput.blur();
await page.waitForTimeout(300);
// Check validity
const validity = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valid);
expect(validity).toBe(true);
});
test('should prevent submission with validation errors', async ({ page }) => {
const formCount = await page.locator('form').count();
if (formCount === 0) {
test.skip();
return;
}
const form = page.locator('form').first();
const currentUrl = page.url();
// Try to submit form with invalid data
const submitButton = form.locator('button[type="submit"]');
await submitButton.click();
// Wait a bit
await page.waitForTimeout(1000);
// URL should not change if validation failed
// (assuming no AJAX submission that stays on page)
const newUrl = page.url();
// Form should still be visible (not submitted)
await expect(form).toBeVisible();
});
test('should handle required fields', async ({ page }) => {
const requiredInputs = page.locator('input[required], textarea[required], select[required]');
const requiredCount = await requiredInputs.count();
if (requiredCount === 0) {
test.skip();
return;
}
// Check first required field
const firstRequired = requiredInputs.first();
// Should have required attribute
await expect(firstRequired).toHaveAttribute('required');
// Should have appropriate ARIA attributes
const ariaRequired = await firstRequired.getAttribute('aria-required');
expect(['true', null]).toContain(ariaRequired);
});
test('should handle max length validation', async ({ page }) => {
const maxLengthInputs = page.locator('input[maxlength], textarea[maxlength]');
const count = await maxLengthInputs.count();
if (count === 0) {
test.skip();
return;
}
const input = maxLengthInputs.first();
const maxLength = await input.getAttribute('maxlength');
if (maxLength) {
const maxLengthNum = parseInt(maxLength, 10);
// Try to enter text longer than maxlength
const longText = 'a'.repeat(maxLengthNum + 10);
await input.fill(longText);
// Value should be truncated to maxlength
const value = await input.inputValue();
expect(value.length).toBeLessThanOrEqual(maxLengthNum);
}
});
test('should show character counter for textarea (if present)', async ({ page }) => {
const textarea = page.locator('textarea[maxlength]').first();
const count = await textarea.count();
if (count === 0) {
test.skip();
return;
}
// Look for character counter
const counter = page.locator('[data-char-counter], .char-counter, .character-count');
const counterCount = await counter.count();
// Enter some text
await textarea.fill('Hello World');
// If counter exists, it should update
if (counterCount > 0) {
await page.waitForTimeout(500);
const counterText = await counter.first().textContent();
expect(counterText).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,200 @@
import { test, expect } from '@playwright/test';
/**
* LiveComponents: Real-time Updates Tests
*
* Tests SSE (Server-Sent Events) and real-time component updates
*/
test.describe('LiveComponents Real-time Updates', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should establish SSE connection (if present)', async ({ page }) => {
// Listen for EventSource connections
const sseConnections: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/sse') || request.headers()['accept']?.includes('text/event-stream')) {
sseConnections.push(request.url());
}
});
// Wait for potential SSE connections
await page.waitForTimeout(2000);
// If SSE is implemented, we should have connections
// This is informational - not all pages may have SSE
console.log('SSE Connections:', sseConnections);
});
test('should handle component updates via HTMX', async ({ page }) => {
// Look for HTMX-enabled elements
const htmxElements = page.locator('[hx-get], [hx-post], [hx-trigger]');
const count = await htmxElements.count();
if (count === 0) {
test.skip();
return;
}
// Track HTMX requests
let htmxRequestMade = false;
page.on('request', (request) => {
const headers = request.headers();
if (headers['hx-request'] === 'true') {
htmxRequestMade = true;
}
});
// Trigger first HTMX element
const firstElement = htmxElements.first();
await firstElement.click();
// Wait for request
await page.waitForTimeout(1000);
// HTMX request should have been made
expect(htmxRequestMade).toBe(true);
});
test('should update DOM without full page reload', async ({ page }) => {
const htmxElements = page.locator('[hx-get], [hx-post]');
const count = await htmxElements.count();
if (count === 0) {
test.skip();
return;
}
// Track page loads
let pageReloaded = false;
page.on('load', () => {
pageReloaded = true;
});
// Get initial page state
const initialUrl = page.url();
// Trigger HTMX action
await htmxElements.first().click();
await page.waitForTimeout(1500);
// URL should not change (unless it's a navigation)
const currentUrl = page.url();
// Page should not have reloaded for simple updates
// (this is a soft check as some actions might navigate)
if (currentUrl === initialUrl) {
expect(pageReloaded).toBe(false);
}
});
test('should handle loading states during updates', async ({ page }) => {
// Look for loading indicators
const loadingIndicators = page.locator('[hx-indicator], .htmx-request, [data-loading]');
// Trigger update if HTMX elements exist
const htmxElements = page.locator('[hx-get], [hx-post]');
const count = await htmxElements.count();
if (count === 0) {
test.skip();
return;
}
// Trigger action and check for loading state
await htmxElements.first().click();
// During request, loading indicator might be visible
await page.waitForTimeout(100);
// After request, loading should be hidden
await page.waitForTimeout(2000);
const loadingCount = await loadingIndicators.count();
console.log('Loading indicators found:', loadingCount);
});
test('should preserve form data during partial updates', async ({ page }) => {
const forms = page.locator('form');
const formCount = await forms.count();
if (formCount === 0) {
test.skip();
return;
}
const form = forms.first();
const inputs = form.locator('input[type="text"], input[type="email"]');
const inputCount = await inputs.count();
if (inputCount === 0) {
test.skip();
return;
}
// Fill some data
const testValue = 'test-value-123';
await inputs.first().fill(testValue);
// Wait a bit
await page.waitForTimeout(500);
// Value should still be present
const currentValue = await inputs.first().inputValue();
expect(currentValue).toBe(testValue);
});
test('should handle WebSocket connections (if present)', async ({ page }) => {
// Track WebSocket connections
const wsConnections: string[] = [];
page.on('websocket', (ws) => {
wsConnections.push(ws.url());
ws.on('framesent', (event) => {
console.log('WebSocket sent:', event.payload);
});
ws.on('framereceived', (event) => {
console.log('WebSocket received:', event.payload);
});
});
// Wait for potential WebSocket connections
await page.waitForTimeout(2000);
// Log WebSocket connections (informational)
console.log('WebSocket Connections:', wsConnections);
// If WebSocket is used, we should have connections
if (wsConnections.length > 0) {
expect(wsConnections.length).toBeGreaterThan(0);
}
});
test('should handle connection errors gracefully', async ({ page }) => {
// Monitor console errors
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
// Navigate and wait
await page.goto('/');
await page.waitForTimeout(3000);
// Filter out known/expected errors
const criticalErrors = errors.filter(
(error) => !error.includes('favicon') && !error.includes('DevTools')
);
// Should not have critical JavaScript errors
expect(criticalErrors.length).toBe(0);
});
});

View File

@@ -0,0 +1,302 @@
# LiveComponent E2E Tests
End-to-End tests for LiveComponent system functionality.
## Prerequisites
E2E tests require:
- Running development server (`npm run dev`)
- Test components deployed at test URLs
- Playwright or similar E2E testing framework
## Test Setup
### Option 1: Playwright (Recommended)
```bash
npm install --save-dev @playwright/test
npx playwright install
```
### Option 2: Puppeteer
```bash
npm install --save-dev puppeteer jest-puppeteer
```
## Planned E2E Test Suites
### Fragment Updates E2E Tests
**File**: `FragmentUpdates.e2e.test.js`
**Test Scenarios**:
1. **Single Fragment Update**
- Load component with multiple fragments
- Trigger action that updates single fragment
- Verify only target fragment updated
- Verify other fragments unchanged
- Verify focus preserved
2. **Multiple Fragment Updates**
- Trigger action with `data-lc-fragments="frag1,frag2"`
- Verify both fragments updated
- Verify unused fragments unchanged
- Measure DOM mutation count
3. **Fragment Update with Events**
- Update fragment that dispatches events
- Verify event received by listeners
- Verify event payload correct
4. **Nested Fragment Updates**
- Update parent fragment containing child fragments
- Verify proper nested patching
- Verify no child components destroyed
5. **Fragment Update Performance**
- Measure time for fragment update vs full render
- Verify 70-95% DOM operation reduction
- Measure bandwidth difference (60-90% reduction)
**Example Implementation**:
```javascript
describe('Fragment Updates E2E', () => {
beforeEach(async () => {
await page.goto('http://localhost:5173/live-component/demo');
});
test('updates single fragment without affecting others', async () => {
// Find component
const component = await page.$('[data-live-component="user-stats:1"]');
// Get initial fragment contents
const initialHeader = await component.$eval(
'[data-lc-fragment="header"]',
el => el.textContent
);
const initialStats = await component.$eval(
'[data-lc-fragment="stats"]',
el => el.textContent
);
// Trigger action that updates only stats fragment
await component.$eval(
'[data-live-action="refreshStats"][data-lc-fragments="stats"]',
btn => btn.click()
);
// Wait for update
await page.waitForTimeout(100);
// Verify stats updated
const newStats = await component.$eval(
'[data-lc-fragment="stats"]',
el => el.textContent
);
expect(newStats).not.toBe(initialStats);
// Verify header unchanged
const newHeader = await component.$eval(
'[data-lc-fragment="header"]',
el => el.textContent
);
expect(newHeader).toBe(initialHeader);
});
test('preserves focus during fragment update', async () => {
const input = await page.$('[data-live-component] input[name="search"]');
// Focus input and type
await input.focus();
await input.type('test query');
// Trigger fragment update
await page.click('[data-live-action="search"][data-lc-fragments="results"]');
await page.waitForTimeout(100);
// Verify focus preserved
const focusedElement = await page.evaluate(() => document.activeElement.name);
expect(focusedElement).toBe('search');
// Verify selection preserved
const selectionStart = await input.evaluate(el => el.selectionStart);
const selectionEnd = await input.evaluate(el => el.selectionEnd);
expect(selectionStart).toBe(10);
expect(selectionEnd).toBe(10);
});
});
```
### Multi-Component Batch Updates E2E Tests
**File**: `BatchUpdates.e2e.test.js`
**Test Scenarios**:
1. **Automatic Batching**
- Queue multiple operations within 50ms window
- Verify single HTTP request sent
- Verify all components updated correctly
2. **Manual Batch Execution**
- Execute batch with `LiveComponent.executeBatch()`
- Verify all operations processed
- Verify partial failures handled gracefully
3. **Batch with Fragments**
- Batch multiple fragment updates
- Verify fragments updated efficiently
- Measure HTTP request reduction (60-80%)
4. **Batch Performance**
- Compare batch vs individual requests
- Measure latency reduction
- Measure bandwidth reduction (~40%)
5. **Batch Error Handling**
- Trigger batch with some invalid operations
- Verify failed operations reported
- Verify successful operations still applied
- Verify error isolation (no cascade failures)
**Example Implementation**:
```javascript
describe('Batch Updates E2E', () => {
beforeEach(async () => {
await page.goto('http://localhost:5173/live-component/batch-demo');
});
test('batches multiple operations automatically', async () => {
// Monitor network requests
const requests = [];
page.on('request', req => {
if (req.url().includes('/live-component/batch')) {
requests.push(req);
}
});
// Trigger multiple operations quickly
await page.evaluate(() => {
LiveComponent.queueBatchOperation({
componentId: 'counter:1',
method: 'increment'
});
LiveComponent.queueBatchOperation({
componentId: 'counter:2',
method: 'increment'
});
LiveComponent.queueBatchOperation({
componentId: 'counter:3',
method: 'increment'
});
});
// Wait for batch to flush
await page.waitForTimeout(100);
// Verify single batch request
expect(requests.length).toBe(1);
// Verify all counters updated
const counter1 = await page.$eval('[data-live-component="counter:1"]', el => el.textContent);
const counter2 = await page.$eval('[data-live-component="counter:2"]', el => el.textContent);
const counter3 = await page.$eval('[data-live-component="counter:3"]', el => el.textContent);
expect(counter1).toContain('1');
expect(counter2).toContain('1');
expect(counter3).toContain('1');
});
test('handles partial batch failures gracefully', async () => {
const result = await page.evaluate(async () => {
return await LiveComponent.executeBatch([
{ componentId: 'valid:1', method: 'validAction' },
{ componentId: 'invalid:999', method: 'nonexistent' },
{ componentId: 'valid:2', method: 'validAction' }
], { autoApply: false });
});
expect(result.total_operations).toBe(3);
expect(result.success_count).toBe(2);
expect(result.failure_count).toBe(1);
// Verify successful operations applied
const valid1 = await page.$('[data-live-component="valid:1"]');
const valid2 = await page.$('[data-live-component="valid:2"]');
expect(valid1).not.toBeNull();
expect(valid2).not.toBeNull();
});
});
```
## Running E2E Tests
### Development
```bash
# Start dev server
npm run dev
# Run E2E tests in another terminal
npm run test:e2e
# Run with UI
npm run test:e2e:ui
# Debug mode
npm run test:e2e:debug
```
### CI/CD
```bash
# Headless mode
npm run test:e2e:ci
```
## Test Components
E2E tests require test components deployed at known URLs:
- `/live-component/demo` - Basic fragment update demo
- `/live-component/batch-demo` - Batch update demo
- `/live-component/performance` - Performance benchmark page
## Performance Validation
E2E tests should validate the performance claims:
- **Fragment Updates**: 60-90% bandwidth reduction, 70-95% DOM operation reduction
- **Request Batching**: 60-80% request reduction, ~40% total bytes reduction
## Visual Regression Testing
Consider adding visual regression tests using:
- Playwright's screenshot comparison
- Percy.io
- Chromatic
## Accessibility Testing
E2E tests should include a11y checks:
- Focus management during updates
- ARIA live regions for dynamic content
- Keyboard navigation preservation
## Browser Coverage
Test across:
- Chrome/Chromium
- Firefox
- Safari
- Edge
## Future Enhancements
- [ ] Implement full Playwright test suite
- [ ] Add visual regression tests
- [ ] Add accessibility tests
- [ ] Add mobile device tests
- [ ] Add network throttling tests
- [ ] Add WebSocket/SSE update tests
- [ ] Add offline mode tests (PWA)

View File

@@ -0,0 +1,467 @@
/**
* LiveComponents E2E Tests - Request Batching
*
* Tests for automatic request batching functionality:
* - Multiple actions batched into single HTTP request
* - Batch payload structure and response handling
* - Error handling for failed actions in batch
* - Batch size limits and automatic debouncing
* - Network efficiency validation
*
* @see src/Framework/LiveComponents/Services/BatchRequestHandler.php
* @see resources/js/modules/LiveComponent.js (batch handling)
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - Request Batching', () => {
test.beforeEach(async ({ page }) => {
// Navigate to test page with LiveComponent
await page.goto('/livecomponents/test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should batch multiple rapid actions into single request', async ({ page }) => {
// Track network requests
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Trigger multiple actions rapidly (should be batched)
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
// Wait for batch to be sent (debounced)
await page.waitForTimeout(100);
// Should only have 1 request (batched)
expect(requests.length).toBe(1);
// Verify batch request structure
const request = requests[0];
const postData = request.postData();
expect(postData).toBeTruthy();
const payload = JSON.parse(postData);
expect(payload).toHaveProperty('batch');
expect(payload.batch).toBeInstanceOf(Array);
expect(payload.batch.length).toBe(3);
});
test('should handle batch response with multiple action results', async ({ page }) => {
// Trigger batched actions
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="updateTitle"]');
await page.click('[data-lc-action="toggleFlag"]');
// Wait for batch response
const response = await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const responseData = await response.json();
// Verify batch response structure
expect(responseData).toHaveProperty('batch_results');
expect(responseData.batch_results).toBeInstanceOf(Array);
expect(responseData.batch_results.length).toBe(3);
// Verify each result has required fields
responseData.batch_results.forEach(result => {
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('html');
expect(result).toHaveProperty('action_index');
});
// Verify component state was updated correctly
const counter = await page.locator('.counter-value').textContent();
expect(parseInt(counter)).toBeGreaterThan(0);
});
test('should handle partial batch failure gracefully', async ({ page }) => {
// Trigger batch with one failing action
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="failingAction"]'); // This will fail
await page.click('[data-lc-action="updateTitle"]');
await page.waitForTimeout(100);
const response = await page.waitForResponse(response =>
response.url().includes('/livecomponent')
);
const responseData = await response.json();
// First action should succeed
expect(responseData.batch_results[0].success).toBe(true);
// Second action should fail
expect(responseData.batch_results[1].success).toBe(false);
expect(responseData.batch_results[1]).toHaveProperty('error');
// Third action should still execute
expect(responseData.batch_results[2].success).toBe(true);
// Verify error notification was shown
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
});
test('should respect batch size limits', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Trigger more actions than batch limit (default: 10)
for (let i = 0; i < 15; i++) {
await page.click('[data-lc-action="increment"]');
}
await page.waitForTimeout(200);
// Should have 2 requests (batch size limit: 10 + 5)
expect(requests.length).toBeGreaterThanOrEqual(2);
// First batch should have 10 actions
const firstBatchData = JSON.parse(requests[0].postData());
expect(firstBatchData.batch.length).toBeLessThanOrEqual(10);
// Second batch should have remaining actions
const secondBatchData = JSON.parse(requests[1].postData());
expect(secondBatchData.batch.length).toBeLessThanOrEqual(5);
});
test('should debounce rapid actions before batching', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Click rapidly within debounce window (default: 50ms)
await page.click('[data-lc-action="increment"]');
await page.waitForTimeout(10);
await page.click('[data-lc-action="increment"]');
await page.waitForTimeout(10);
await page.click('[data-lc-action="increment"]');
// Wait for debounce + network
await page.waitForTimeout(150);
// Should only have 1 batched request
expect(requests.length).toBe(1);
// Click after debounce window expires
await page.waitForTimeout(100);
await page.click('[data-lc-action="increment"]');
await page.waitForTimeout(150);
// Should have 2 requests total
expect(requests.length).toBe(2);
});
test('should send immediate request when batch is manually flushed', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Trigger action
await page.click('[data-lc-action="increment"]');
// Manually flush batch before debounce expires
await page.evaluate(() => {
window.LiveComponent.flushBatch();
});
// Wait for immediate request
await page.waitForTimeout(50);
// Should have sent request immediately
expect(requests.length).toBe(1);
});
test('should validate network efficiency with batching', async ({ page }) => {
// Count requests WITHOUT batching
await page.evaluate(() => {
window.LiveComponent.disableBatching();
});
const unbatchedRequests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
unbatchedRequests.push(request);
}
});
// Trigger 5 actions
for (let i = 0; i < 5; i++) {
await page.click('[data-lc-action="increment"]');
await page.waitForTimeout(20);
}
await page.waitForTimeout(200);
const unbatchedCount = unbatchedRequests.length;
expect(unbatchedCount).toBe(5); // Should be 5 separate requests
// Reload and test WITH batching
await page.reload();
await page.waitForFunction(() => window.LiveComponent !== undefined);
const batchedRequests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
batchedRequests.push(request);
}
});
// Trigger same 5 actions
for (let i = 0; i < 5; i++) {
await page.click('[data-lc-action="increment"]');
}
await page.waitForTimeout(200);
const batchedCount = batchedRequests.length;
expect(batchedCount).toBe(1); // Should be 1 batched request
// Network efficiency: 80% reduction in requests
const efficiency = ((unbatchedCount - batchedCount) / unbatchedCount) * 100;
expect(efficiency).toBeGreaterThanOrEqual(80);
});
test('should preserve action order in batch', async ({ page }) => {
// Trigger actions in specific order
await page.click('[data-lc-action="setValueA"]');
await page.click('[data-lc-action="setValueB"]');
await page.click('[data-lc-action="setValueC"]');
const response = await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const responseData = await response.json();
// Verify action_index preserves order
expect(responseData.batch_results[0].action_index).toBe(0);
expect(responseData.batch_results[1].action_index).toBe(1);
expect(responseData.batch_results[2].action_index).toBe(2);
// Verify final state reflects correct order
const finalValue = await page.locator('.value-display').textContent();
expect(finalValue).toBe('C'); // Last action wins
});
test('should include component state in batch request', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Update state and trigger batch
await page.fill('[data-lc-model="searchTerm"]', 'test query');
await page.click('[data-lc-action="search"]');
await page.click('[data-lc-action="filter"]');
await page.waitForTimeout(100);
const request = requests[0];
const payload = JSON.parse(request.postData());
// Verify state is included
expect(payload).toHaveProperty('state');
expect(payload.state.searchTerm).toBe('test query');
// Verify batch actions
expect(payload.batch.length).toBe(2);
expect(payload.batch[0].action).toBe('search');
expect(payload.batch[1].action).toBe('filter');
});
test('should handle batch with different action parameters', async ({ page }) => {
// Trigger actions with different params
await page.evaluate(() => {
const componentId = document.querySelector('[data-component-id]').dataset.componentId;
window.LiveComponent.executeAction(componentId, 'updateField', { field: 'name', value: 'John' });
window.LiveComponent.executeAction(componentId, 'updateField', { field: 'email', value: 'john@example.com' });
window.LiveComponent.executeAction(componentId, 'updateField', { field: 'age', value: 30 });
});
const response = await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const responseData = await response.json();
// Verify all 3 actions were batched
expect(responseData.batch_results.length).toBe(3);
// Verify each action maintained its parameters
expect(responseData.batch_results[0].success).toBe(true);
expect(responseData.batch_results[1].success).toBe(true);
expect(responseData.batch_results[2].success).toBe(true);
// Verify final component state
const nameField = await page.locator('[data-field="name"]').textContent();
const emailField = await page.locator('[data-field="email"]').textContent();
const ageField = await page.locator('[data-field="age"]').textContent();
expect(nameField).toBe('John');
expect(emailField).toBe('john@example.com');
expect(ageField).toBe('30');
});
test('should emit batch events for monitoring', async ({ page }) => {
let batchStartEvent = null;
let batchCompleteEvent = null;
// Listen for batch events
await page.exposeFunction('onBatchStart', (event) => {
batchStartEvent = event;
});
await page.exposeFunction('onBatchComplete', (event) => {
batchCompleteEvent = event;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:batch-start', (e) => {
window.onBatchStart(e.detail);
});
window.addEventListener('livecomponent:batch-complete', (e) => {
window.onBatchComplete(e.detail);
});
});
// Trigger batch
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Wait for events
await page.waitForTimeout(100);
// Verify batch-start event
expect(batchStartEvent).not.toBeNull();
expect(batchStartEvent).toHaveProperty('actionCount');
expect(batchStartEvent.actionCount).toBe(3);
// Verify batch-complete event
expect(batchCompleteEvent).not.toBeNull();
expect(batchCompleteEvent).toHaveProperty('results');
expect(batchCompleteEvent.results.length).toBe(3);
expect(batchCompleteEvent).toHaveProperty('duration');
});
test('should handle concurrent batches correctly', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Trigger first batch
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
// Wait for partial debounce
await page.waitForTimeout(30);
// Trigger second batch (while first is still debouncing)
await page.click('[data-lc-action="updateTitle"]');
await page.click('[data-lc-action="updateTitle"]');
// Wait for all batches to complete
await page.waitForTimeout(200);
// Should have sent batches separately
expect(requests.length).toBeGreaterThanOrEqual(1);
// Verify final state is consistent
const counter = await page.locator('.counter-value').textContent();
expect(parseInt(counter)).toBe(2); // Both increments applied
});
test('should handle batch timeout gracefully', async ({ page }) => {
// Mock slow server response
await page.route('**/livecomponent/**', async route => {
await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second delay
route.continue();
});
// Trigger batch
await page.click('[data-lc-action="increment"]');
// Should show loading state
const loadingIndicator = page.locator('.livecomponent-loading');
await expect(loadingIndicator).toBeVisible();
// Wait for timeout (should be less than 5 seconds)
await page.waitForTimeout(3000);
// Should show timeout error
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText('timeout');
});
test('should allow disabling batching per action', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent')) {
requests.push(request);
}
});
// Trigger batchable actions
await page.click('[data-lc-action="increment"]');
await page.click('[data-lc-action="increment"]');
// Trigger non-batchable action (critical/immediate)
await page.click('[data-lc-action="criticalAction"]');
await page.waitForTimeout(200);
// Should have 2 requests:
// 1. Immediate critical action
// 2. Batched increments
expect(requests.length).toBeGreaterThanOrEqual(2);
// First request should be the critical action (immediate)
const firstRequest = JSON.parse(requests[0].postData());
expect(firstRequest.action).toBe('criticalAction');
expect(firstRequest).not.toHaveProperty('batch');
// Second request should be the batched increments
const secondRequest = JSON.parse(requests[1].postData());
expect(secondRequest).toHaveProperty('batch');
expect(secondRequest.batch.length).toBe(2);
});
});

View File

@@ -0,0 +1,507 @@
/**
* LiveComponents E2E Tests - Chunked Upload with Progress
*
* Tests for large file upload with chunking and progress tracking:
* - File chunking into smaller pieces
* - Sequential chunk upload with retry
* - Real-time progress updates
* - Pause/resume functionality
* - Error handling and recovery
* - Upload cancellation
*
* @see src/Framework/LiveComponents/Services/ChunkedUploadHandler.php
* @see resources/js/modules/LiveComponent.js (chunked upload)
*/
import { test, expect } from '@playwright/test';
import path from 'path';
test.describe('LiveComponents - Chunked Upload', () => {
test.beforeEach(async ({ page }) => {
// Navigate to upload test page
await page.goto('/livecomponents/upload-test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should chunk large file into multiple pieces', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent/upload')) {
requests.push(request);
}
});
// Create test file (5MB - should be chunked)
const testFile = await page.evaluate(() => {
const size = 5 * 1024 * 1024; // 5MB
const blob = new Blob([new ArrayBuffer(size)], { type: 'application/octet-stream' });
return new File([blob], 'test-file.bin', { type: 'application/octet-stream' });
});
// Set file input
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB
});
// Wait for chunked upload to complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// With 1MB chunk size, should have ~5 chunks
expect(requests.length).toBeGreaterThanOrEqual(5);
expect(requests.length).toBeLessThanOrEqual(6);
// Verify each chunk request has correct headers
requests.forEach((request, index) => {
const headers = request.headers();
expect(headers['x-chunk-index']).toBeDefined();
expect(headers['x-chunk-total']).toBeDefined();
expect(headers['x-upload-id']).toBeDefined();
});
});
test('should track upload progress in real-time', async ({ page }) => {
const progressUpdates = [];
// Monitor progress updates
await page.exposeFunction('onProgressUpdate', (progress) => {
progressUpdates.push(progress);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:upload-progress', (e) => {
window.onProgressUpdate(e.detail);
});
});
// Upload file
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB
});
// Wait for upload to complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Should have received multiple progress updates
expect(progressUpdates.length).toBeGreaterThan(0);
// Progress should increase monotonically
for (let i = 1; i < progressUpdates.length; i++) {
expect(progressUpdates[i].percentage).toBeGreaterThanOrEqual(
progressUpdates[i - 1].percentage
);
}
// Final progress should be 100%
const finalProgress = progressUpdates[progressUpdates.length - 1];
expect(finalProgress.percentage).toBe(100);
});
test('should display progress bar during upload', async ({ page }) => {
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
});
// Progress bar should appear
const progressBar = page.locator('.upload-progress-bar');
await expect(progressBar).toBeVisible();
// Progress percentage should be visible
const progressText = page.locator('.upload-progress-text');
await expect(progressText).toBeVisible();
// Wait for progress to reach 100%
await expect(progressText).toContainText('100%', { timeout: 30000 });
// Progress bar should hide or show completion
await expect(progressBar).toHaveClass(/complete|success/);
});
test('should handle pause and resume correctly', async ({ page }) => {
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB
});
// Wait for upload to start
await page.waitForSelector('.upload-progress-bar');
// Wait for some progress
await page.waitForTimeout(500);
// Pause upload
await page.click('[data-action="pause-upload"]');
const progressTextBefore = await page.locator('.upload-progress-text').textContent();
const percentageBefore = parseInt(progressTextBefore);
// Wait to ensure upload is paused
await page.waitForTimeout(1000);
const progressTextAfter = await page.locator('.upload-progress-text').textContent();
const percentageAfter = parseInt(progressTextAfter);
// Progress should not have changed (paused)
expect(percentageAfter).toBe(percentageBefore);
// Resume upload
await page.click('[data-action="resume-upload"]');
// Wait for upload to complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Final progress should be 100%
const finalProgress = await page.locator('.upload-progress-text').textContent();
expect(finalProgress).toContain('100%');
});
test('should allow cancelling upload', async ({ page }) => {
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB
});
// Wait for upload to start
await page.waitForSelector('.upload-progress-bar');
// Wait for some progress
await page.waitForTimeout(500);
// Cancel upload
await page.click('[data-action="cancel-upload"]');
// Progress bar should disappear or show cancelled state
await expect(page.locator('.upload-progress-bar')).not.toBeVisible();
// Cancelled notification should appear
const notification = page.locator('.upload-notification');
await expect(notification).toBeVisible();
await expect(notification).toContainText(/cancel/i);
});
test('should retry failed chunks automatically', async ({ page }) => {
let attemptCount = 0;
// Mock server to fail first 2 chunk uploads
await page.route('**/livecomponent/upload/**', async (route) => {
attemptCount++;
if (attemptCount <= 2) {
// Fail first 2 attempts
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Temporary server error' })
});
} else {
// Succeed on retry
route.continue();
}
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(1024 * 1024) // 1MB (single chunk)
});
// Should eventually succeed after retries
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Should have attempted at least 3 times (2 failures + 1 success)
expect(attemptCount).toBeGreaterThanOrEqual(3);
});
test('should handle chunk upload failure with max retries', async ({ page }) => {
// Mock server to always fail
await page.route('**/livecomponent/upload/**', async (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Permanent server error' })
});
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(1024 * 1024) // 1MB
});
// Should show error after max retries
const errorNotification = page.locator('.upload-error');
await expect(errorNotification).toBeVisible({ timeout: 30000 });
await expect(errorNotification).toContainText(/failed|error/i);
// Progress bar should show error state
const progressBar = page.locator('.upload-progress-bar');
await expect(progressBar).toHaveClass(/error|failed/);
});
test('should upload multiple files sequentially', async ({ page }) => {
const fileInput = page.locator('input[type="file"][data-lc-upload][multiple]');
// Select multiple files
await fileInput.setInputFiles([
{
name: 'file1.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
},
{
name: 'file2.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
},
{
name: 'file3.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
}
]);
// Should show multiple progress bars or list
const uploadItems = page.locator('.upload-item');
await expect(uploadItems).toHaveCount(3);
// Wait for all uploads to complete
await page.waitForSelector('.all-uploads-complete', { timeout: 60000 });
// All items should show completion
const completedItems = page.locator('.upload-item.complete');
await expect(completedItems).toHaveCount(3);
});
test('should preserve chunk order during upload', async ({ page }) => {
const chunkOrder = [];
// Monitor chunk requests
page.on('request', request => {
if (request.url().includes('/livecomponent/upload')) {
const headers = request.headers();
const chunkIndex = parseInt(headers['x-chunk-index']);
chunkOrder.push(chunkIndex);
}
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB
});
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Chunks should be uploaded in order (0, 1, 2, ...)
for (let i = 0; i < chunkOrder.length; i++) {
expect(chunkOrder[i]).toBe(i);
}
});
test('should include upload metadata in chunk requests', async ({ page }) => {
const requests = [];
page.on('request', request => {
if (request.url().includes('/livecomponent/upload')) {
requests.push(request);
}
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'document.pdf',
mimeType: 'application/pdf',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
});
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// First chunk should have file metadata
const firstRequest = requests[0];
const headers = firstRequest.headers();
expect(headers['x-file-name']).toBe('document.pdf');
expect(headers['x-file-size']).toBeDefined();
expect(headers['x-file-type']).toBe('application/pdf');
expect(headers['x-upload-id']).toBeDefined();
});
test('should show upload speed and time remaining', async ({ page }) => {
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024) // 5MB
});
// Wait for upload to start
await page.waitForSelector('.upload-progress-bar');
// Speed indicator should be visible
const speedIndicator = page.locator('.upload-speed');
await expect(speedIndicator).toBeVisible();
await expect(speedIndicator).toContainText(/MB\/s|KB\/s/);
// Time remaining should be visible
const timeRemaining = page.locator('.upload-time-remaining');
await expect(timeRemaining).toBeVisible();
await expect(timeRemaining).toContainText(/second|minute/);
});
test('should handle file size validation before upload', async ({ page }) => {
// Mock large file exceeding limit (e.g., 100MB limit)
const fileInput = page.locator('input[type="file"][data-lc-upload]');
// Set max file size via data attribute
await page.evaluate(() => {
const input = document.querySelector('input[type="file"][data-lc-upload]');
input.dataset.maxFileSize = '10485760'; // 10MB limit
});
// Try to upload 20MB file
await fileInput.setInputFiles({
name: 'large-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(20 * 1024 * 1024) // 20MB
});
// Should show validation error
const errorNotification = page.locator('.upload-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/too large|size limit/i);
// Upload should not start
const progressBar = page.locator('.upload-progress-bar');
await expect(progressBar).not.toBeVisible();
});
test('should handle file type validation before upload', async ({ page }) => {
// Set allowed file types
await page.evaluate(() => {
const input = document.querySelector('input[type="file"][data-lc-upload]');
input.dataset.allowedTypes = 'image/jpeg,image/png,image/gif';
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
// Try to upload disallowed file type
await fileInput.setInputFiles({
name: 'document.pdf',
mimeType: 'application/pdf',
buffer: Buffer.alloc(1024 * 1024) // 1MB
});
// Should show validation error
const errorNotification = page.locator('.upload-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/file type|not allowed/i);
});
test('should emit upload events for monitoring', async ({ page }) => {
const events = {
start: null,
progress: [],
complete: null
};
await page.exposeFunction('onUploadStart', (event) => {
events.start = event;
});
await page.exposeFunction('onUploadProgress', (event) => {
events.progress.push(event);
});
await page.exposeFunction('onUploadComplete', (event) => {
events.complete = event;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:upload-start', (e) => {
window.onUploadStart(e.detail);
});
window.addEventListener('livecomponent:upload-progress', (e) => {
window.onUploadProgress(e.detail);
});
window.addEventListener('livecomponent:upload-complete', (e) => {
window.onUploadComplete(e.detail);
});
});
// Upload file
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(2 * 1024 * 1024) // 2MB
});
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Wait for events
await page.waitForTimeout(500);
// Verify start event
expect(events.start).not.toBeNull();
expect(events.start).toHaveProperty('fileName');
expect(events.start).toHaveProperty('fileSize');
// Verify progress events
expect(events.progress.length).toBeGreaterThan(0);
events.progress.forEach(event => {
expect(event).toHaveProperty('percentage');
expect(event).toHaveProperty('uploadedBytes');
});
// Verify complete event
expect(events.complete).not.toBeNull();
expect(events.complete).toHaveProperty('uploadId');
expect(events.complete).toHaveProperty('duration');
});
test('should handle network interruption and resume', async ({ page }) => {
let requestCount = 0;
// Simulate network interruption
await page.route('**/livecomponent/upload/**', async (route) => {
requestCount++;
if (requestCount === 2) {
// Interrupt on 2nd chunk
route.abort('failed');
} else {
route.continue();
}
});
const fileInput = page.locator('input[type="file"][data-lc-upload]');
await fileInput.setInputFiles({
name: 'test-file.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(3 * 1024 * 1024) // 3MB (3 chunks)
});
// Should retry and eventually complete
await page.waitForSelector('.upload-complete', { timeout: 30000 });
// Should have attempted more than 3 times due to retry
expect(requestCount).toBeGreaterThan(3);
});
});

View File

@@ -0,0 +1,613 @@
/**
* LiveComponents E2E Tests - Error Recovery & Fallbacks
*
* Tests for robust error handling and graceful degradation:
* - Network error recovery
* - Server error handling with user feedback
* - Graceful degradation when JavaScript fails
* - Retry mechanisms with exponential backoff
* - Offline mode detection and handling
* - State recovery after errors
*
* @see src/Framework/LiveComponents/Services/ErrorRecoveryService.php
* @see resources/js/modules/LiveComponent.js (error handling)
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - Error Recovery & Fallbacks', () => {
test.beforeEach(async ({ page }) => {
// Navigate to error recovery test page
await page.goto('/livecomponents/error-test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should show user-friendly error message on network failure', async ({ page }) => {
// Simulate network failure
await page.route('**/livecomponent/**', route => route.abort('failed'));
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show error notification
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/network|connection|failed/i);
// Should offer retry option
const retryButton = page.locator('[data-action="retry"]');
await expect(retryButton).toBeVisible();
});
test('should automatically retry on transient network errors', async ({ page }) => {
let attemptCount = 0;
// Fail first 2 attempts, succeed on 3rd
await page.route('**/livecomponent/**', route => {
attemptCount++;
if (attemptCount <= 2) {
route.abort('failed');
} else {
route.continue();
}
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should eventually succeed after retries
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200,
{ timeout: 10000 }
);
// Success notification
const successNotification = page.locator('.livecomponent-success');
await expect(successNotification).toBeVisible();
// Should have attempted 3 times
expect(attemptCount).toBe(3);
});
test('should use exponential backoff for retries', async ({ page }) => {
const retryAttempts = [];
await page.exposeFunction('onRetryAttempt', (attempt) => {
retryAttempts.push(attempt);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:retry-attempt', (e) => {
window.onRetryAttempt({
timestamp: Date.now(),
attemptNumber: e.detail.attemptNumber,
delay: e.detail.delay
});
});
});
// Simulate multiple failures
await page.route('**/livecomponent/**', route => route.abort('failed'));
// Trigger action
await page.click('[data-lc-action="submit"]');
// Wait for multiple retry attempts
await page.waitForTimeout(10000);
// Should have multiple attempts with increasing delays
expect(retryAttempts.length).toBeGreaterThan(1);
// Delays should increase exponentially
if (retryAttempts.length >= 3) {
const delay1 = retryAttempts[1].delay;
const delay2 = retryAttempts[2].delay;
expect(delay2).toBeGreaterThan(delay1);
}
});
test('should stop retrying after max attempts', async ({ page }) => {
let attemptCount = 0;
// Always fail
await page.route('**/livecomponent/**', route => {
attemptCount++;
route.abort('failed');
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Wait for all retry attempts
await page.waitForTimeout(15000);
// Should stop after max attempts (typically 3-5)
expect(attemptCount).toBeLessThanOrEqual(5);
// Should show final error message
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/unable|failed|try again later/i);
});
test('should handle server 500 errors gracefully', async ({ page }) => {
// Mock 500 error
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' })
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show server error message
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/server error|something went wrong/i);
// Should log error for debugging
const consoleLogs = [];
page.on('console', msg => {
if (msg.type() === 'error') {
consoleLogs.push(msg.text());
}
});
await page.waitForTimeout(500);
// Should have logged error details
expect(consoleLogs.some(log => log.includes('500'))).toBe(true);
});
test('should handle validation errors with field-specific feedback', async ({ page }) => {
// Mock validation errors
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 422,
body: JSON.stringify({
success: false,
errors: {
email: 'Email is invalid',
password: 'Password must be at least 8 characters'
}
})
});
});
// Trigger form submission
await page.click('[data-lc-action="submit"]');
// Wait for validation errors
await page.waitForTimeout(1000);
// Should show field-specific errors
const emailError = page.locator('.validation-error[data-field="email"]');
await expect(emailError).toBeVisible();
await expect(emailError).toContainText('Email is invalid');
const passwordError = page.locator('.validation-error[data-field="password"]');
await expect(passwordError).toBeVisible();
await expect(passwordError).toContainText('at least 8 characters');
// Form should still be editable
const emailInput = page.locator('input[name="email"]');
await expect(emailInput).toBeEnabled();
});
test('should detect offline mode and queue actions', async ({ page }) => {
// Simulate going offline
await page.context().setOffline(true);
// Trigger action while offline
await page.click('[data-lc-action="submit"]');
// Should show offline notification
const offlineNotification = page.locator('.livecomponent-offline');
await expect(offlineNotification).toBeVisible();
await expect(offlineNotification).toContainText(/offline|no connection/i);
// Action should be queued
const queuedActions = await page.evaluate(() => {
return window.LiveComponent.getQueuedActions();
});
expect(queuedActions.length).toBeGreaterThan(0);
// Go back online
await page.context().setOffline(false);
// Wait for auto-retry
await page.waitForResponse(response =>
response.url().includes('/livecomponent'),
{ timeout: 10000 }
);
// Queued actions should be executed
const remainingActions = await page.evaluate(() => {
return window.LiveComponent.getQueuedActions();
});
expect(remainingActions.length).toBe(0);
});
test('should preserve component state after error recovery', async ({ page }) => {
// Fill form
await page.fill('input[name="title"]', 'Test Title');
await page.fill('input[name="description"]', 'Test Description');
// Mock temporary error
let failOnce = true;
await page.route('**/livecomponent/**', route => {
if (failOnce) {
failOnce = false;
route.abort('failed');
} else {
route.continue();
}
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Wait for retry and success
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200,
{ timeout: 10000 }
);
// Form values should be preserved
await expect(page.locator('input[name="title"]')).toHaveValue('Test Title');
await expect(page.locator('input[name="description"]')).toHaveValue('Test Description');
});
test('should fallback to full page reload on critical JavaScript error', async ({ page }) => {
// Inject JavaScript error in LiveComponent
await page.evaluate(() => {
const originalHandle = window.LiveComponent.handleAction;
window.LiveComponent.handleAction = function() {
throw new Error('Critical JavaScript error');
};
});
// Track page reload
let pageReloaded = false;
page.on('load', () => {
pageReloaded = true;
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Wait for fallback
await page.waitForTimeout(3000);
// Should fallback to traditional form submission or page reload
// (depending on framework implementation)
const errorFallback = page.locator('.livecomponent-fallback');
const hasErrorFallback = await errorFallback.isVisible().catch(() => false);
// Either fallback indicator or page reload should occur
expect(hasErrorFallback || pageReloaded).toBe(true);
});
test('should handle timeout errors with user feedback', async ({ page }) => {
// Mock slow server (timeout)
await page.route('**/livecomponent/**', async route => {
await new Promise(resolve => setTimeout(resolve, 10000));
route.continue();
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show timeout error (before 10s)
const timeoutError = page.locator('.livecomponent-timeout');
await expect(timeoutError).toBeVisible({ timeout: 6000 });
await expect(timeoutError).toContainText(/timeout|taking too long/i);
// Should offer retry or cancel
const retryButton = page.locator('[data-action="retry"]');
const cancelButton = page.locator('[data-action="cancel"]');
await expect(retryButton).toBeVisible();
await expect(cancelButton).toBeVisible();
});
test('should allow manual retry after error', async ({ page }) => {
// Mock error
let shouldFail = true;
await page.route('**/livecomponent/**', route => {
if (shouldFail) {
route.abort('failed');
} else {
route.continue();
}
});
// Trigger action (will fail)
await page.click('[data-lc-action="submit"]');
// Wait for error
await expect(page.locator('.livecomponent-error')).toBeVisible();
// Fix the error condition
shouldFail = false;
// Click retry button
await page.click('[data-action="retry"]');
// Should succeed on retry
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const successNotification = page.locator('.livecomponent-success');
await expect(successNotification).toBeVisible();
});
test('should handle CSRF token expiration', async ({ page }) => {
// Mock CSRF token error
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 403,
body: JSON.stringify({ error: 'CSRF token mismatch' })
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should detect CSRF error
const csrfError = page.locator('.livecomponent-csrf-error');
await expect(csrfError).toBeVisible();
// Should automatically refresh CSRF token and retry
// (framework-specific implementation)
await expect(csrfError).toContainText(/security|session|refresh/i);
});
test('should show loading state during error recovery', async ({ page }) => {
let attemptCount = 0;
await page.route('**/livecomponent/**', route => {
attemptCount++;
if (attemptCount <= 2) {
setTimeout(() => route.abort('failed'), 500);
} else {
route.continue();
}
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Loading indicator should persist during retries
const loadingIndicator = page.locator('.livecomponent-loading');
await expect(loadingIndicator).toBeVisible();
// Wait for successful retry
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200,
{ timeout: 15000 }
);
// Loading should disappear
await expect(loadingIndicator).not.toBeVisible();
});
test('should emit error events for monitoring', async ({ page }) => {
const errorEvents = [];
await page.exposeFunction('onErrorEvent', (event) => {
errorEvents.push(event);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:error', (e) => {
window.onErrorEvent(e.detail);
});
});
// Trigger error
await page.route('**/livecomponent/**', route => route.abort('failed'));
await page.click('[data-lc-action="submit"]');
// Wait for error events
await page.waitForTimeout(2000);
// Should have error events
expect(errorEvents.length).toBeGreaterThan(0);
expect(errorEvents[0]).toHaveProperty('type');
expect(errorEvents[0]).toHaveProperty('message');
expect(errorEvents[0]).toHaveProperty('timestamp');
});
test('should recover from partial response errors', async ({ page }) => {
// Mock malformed JSON response
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 200,
body: '{invalid json'
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should handle parse error gracefully
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/error|failed/i);
// Component should remain functional
const component = page.locator('[data-component-id]');
await expect(component).toBeVisible();
});
test('should handle rate limiting with backoff', async ({ page }) => {
// Mock rate limit error
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 429,
headers: { 'Retry-After': '5' },
body: JSON.stringify({ error: 'Too many requests' })
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show rate limit message
const rateLimitNotification = page.locator('.livecomponent-rate-limit');
await expect(rateLimitNotification).toBeVisible();
await expect(rateLimitNotification).toContainText(/too many|rate limit|try again/i);
// Should show countdown timer
const countdown = page.locator('.retry-countdown');
await expect(countdown).toBeVisible();
await expect(countdown).toContainText(/\d+ second/);
});
test('should clear errors when action succeeds', async ({ page }) => {
// First: trigger error
let shouldFail = true;
await page.route('**/livecomponent/**', route => {
if (shouldFail) {
route.abort('failed');
} else {
route.continue();
}
});
await page.click('[data-lc-action="submit"]');
await expect(page.locator('.livecomponent-error')).toBeVisible();
// Second: fix and retry
shouldFail = false;
await page.click('[data-action="retry"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Error should be cleared
await expect(page.locator('.livecomponent-error')).not.toBeVisible();
// Success message should appear
const successNotification = page.locator('.livecomponent-success');
await expect(successNotification).toBeVisible();
});
test('should handle concurrent action errors independently', async ({ page }) => {
// Create two components
await page.evaluate(() => {
const container = document.querySelector('#component-container');
['component-1', 'component-2'].forEach(id => {
const component = document.createElement('div');
component.dataset.componentId = id;
component.innerHTML = `
<button data-lc-action="submit">Submit</button>
<div class="livecomponent-error"></div>
`;
container.appendChild(component);
});
});
// Mock different responses
await page.route('**/livecomponent/**', route => {
const url = route.request().url();
if (url.includes('component-1')) {
route.abort('failed'); // Fail component-1
} else {
route.continue(); // Succeed component-2
}
});
// Trigger both actions
await page.click('#component-1 [data-lc-action="submit"]');
await page.click('#component-2 [data-lc-action="submit"]');
await page.waitForTimeout(2000);
// Component 1 should show error
await expect(page.locator('#component-1 .livecomponent-error')).toBeVisible();
// Component 2 should succeed (no error)
await expect(page.locator('#component-2 .livecomponent-error')).not.toBeVisible();
});
test('should provide debug information in development mode', async ({ page }) => {
// Set development mode
await page.evaluate(() => {
document.documentElement.dataset.env = 'development';
});
// Mock error
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 500,
body: JSON.stringify({
error: 'Database connection failed',
debug: {
file: '/var/www/src/Database/Connection.php',
line: 42,
trace: ['DatabaseException', 'ConnectionPool', 'EntityManager']
}
})
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show debug details in dev mode
const debugInfo = page.locator('.livecomponent-debug');
await expect(debugInfo).toBeVisible();
await expect(debugInfo).toContainText('Connection.php:42');
await expect(debugInfo).toContainText('Database connection failed');
});
test('should hide sensitive debug info in production mode', async ({ page }) => {
// Set production mode
await page.evaluate(() => {
document.documentElement.dataset.env = 'production';
});
// Mock error with debug info
await page.route('**/livecomponent/**', route => {
route.fulfill({
status: 500,
body: JSON.stringify({
error: 'Internal error',
debug: {
file: '/var/www/src/Database/Connection.php',
line: 42,
sensitive_data: 'password=secret123'
}
})
});
});
// Trigger action
await page.click('[data-lc-action="submit"]');
// Should show generic error only
const errorNotification = page.locator('.livecomponent-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).not.toContainText('Connection.php');
await expect(errorNotification).not.toContainText('password');
// Generic message only
await expect(errorNotification).toContainText(/something went wrong|error occurred/i);
});
});

View File

@@ -0,0 +1,490 @@
/**
* LiveComponents E2E Tests - Optimistic UI Updates
*
* Tests for optimistic UI updates with automatic rollback on failure:
* - Immediate UI updates before server response
* - Automatic rollback on server error
* - Conflict resolution
* - Loading states during server confirmation
* - Retry logic for failed optimistic updates
*
* @see src/Framework/LiveComponents/Services/OptimisticUpdateService.php
* @see resources/js/modules/LiveComponent.js (optimistic updates)
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - Optimistic UI Updates', () => {
test.beforeEach(async ({ page }) => {
// Navigate to optimistic UI test page
await page.goto('/livecomponents/optimistic-test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should apply optimistic update immediately', async ({ page }) => {
// Get initial value
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Click increment (optimistic)
const clickTime = Date.now();
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Measure time to UI update
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 1);
const updateTime = Date.now();
const uiUpdateDelay = updateTime - clickTime;
// UI should update within 50ms (optimistic)
expect(uiUpdateDelay).toBeLessThan(50);
// Value should be incremented immediately
const optimisticValue = await page.locator('[data-counter]').textContent();
expect(parseInt(optimisticValue)).toBe(initialCount + 1);
});
test('should show pending state during server confirmation', async ({ page }) => {
// Click action with optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Should show pending indicator
const pendingIndicator = page.locator('.optimistic-pending');
await expect(pendingIndicator).toBeVisible();
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Pending indicator should disappear
await expect(pendingIndicator).not.toBeVisible();
});
test('should rollback optimistic update on server error', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Mock server error
await page.route('**/livecomponent/**', async (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' })
});
} else {
route.continue();
}
});
// Click increment (optimistic)
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI should update optimistically first
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 1);
// Wait for server error and rollback
await page.waitForTimeout(1000);
// Should rollback to original value
const rolledBackValue = await page.locator('[data-counter]').textContent();
expect(parseInt(rolledBackValue)).toBe(initialCount);
// Should show error notification
const errorNotification = page.locator('.optimistic-error');
await expect(errorNotification).toBeVisible();
await expect(errorNotification).toContainText(/failed|error/i);
});
test('should handle multiple rapid optimistic updates', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Click multiple times rapidly
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI should reflect all optimistic updates immediately
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 3);
// Wait for server confirmations
await page.waitForLoadState('networkidle');
// Final value should still be correct after confirmations
const finalValue = await page.locator('[data-counter]').textContent();
expect(parseInt(finalValue)).toBe(initialCount + 3);
});
test('should resolve conflicts with server state', async ({ page }) => {
// Set initial optimistic value
await page.click('[data-lc-action="setValue"][data-optimistic="true"]');
// Immediately update value optimistically
const optimisticValue = await page.locator('[data-value]').textContent();
// Mock server response with different value (conflict)
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
html: '<div data-value>Server Value</div>',
conflict: true
})
});
});
// Wait for server response
await page.waitForTimeout(1000);
// Should resolve to server value (server wins)
const resolvedValue = await page.locator('[data-value]').textContent();
expect(resolvedValue).toBe('Server Value');
// Should show conflict notification
const conflictNotification = page.locator('.optimistic-conflict');
await expect(conflictNotification).toBeVisible();
});
test('should track optimistic update IDs for proper rollback', async ({ page }) => {
const updates = [];
// Monitor optimistic updates
await page.exposeFunction('onOptimisticUpdate', (update) => {
updates.push(update);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:optimistic-update', (e) => {
window.onOptimisticUpdate(e.detail);
});
});
// Trigger optimistic updates
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.waitForTimeout(100);
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.waitForTimeout(100);
// Each update should have unique ID
expect(updates.length).toBeGreaterThanOrEqual(2);
expect(updates[0].updateId).not.toBe(updates[1].updateId);
// IDs should be tracked for rollback
expect(updates[0]).toHaveProperty('updateId');
expect(updates[0]).toHaveProperty('timestamp');
expect(updates[0]).toHaveProperty('action');
});
test('should allow disabling optimistic updates per action', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
// Click non-optimistic action
const clickTime = Date.now();
await page.click('[data-lc-action="incrementNonOptimistic"]');
// Wait for server response
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const updateTime = Date.now();
const totalDelay = updateTime - clickTime;
// Should NOT be optimistic (takes longer, waits for server)
expect(totalDelay).toBeGreaterThan(50);
// Value should only update after server response
const updatedValue = await page.locator('[data-counter]').textContent();
expect(parseInt(updatedValue)).toBe(parseInt(initialValue) + 1);
});
test('should emit optimistic update events', async ({ page }) => {
const events = {
applied: null,
confirmed: null,
rolledBack: null
};
await page.exposeFunction('onOptimisticApplied', (event) => {
events.applied = event;
});
await page.exposeFunction('onOptimisticConfirmed', (event) => {
events.confirmed = event;
});
await page.exposeFunction('onOptimisticRolledBack', (event) => {
events.rolledBack = event;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:optimistic-applied', (e) => {
window.onOptimisticApplied(e.detail);
});
window.addEventListener('livecomponent:optimistic-confirmed', (e) => {
window.onOptimisticConfirmed(e.detail);
});
window.addEventListener('livecomponent:optimistic-rolled-back', (e) => {
window.onOptimisticRolledBack(e.detail);
});
});
// Trigger successful optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Wait for events
await page.waitForTimeout(1500);
// Should have applied event
expect(events.applied).not.toBeNull();
expect(events.applied).toHaveProperty('updateId');
// Should have confirmed event
expect(events.confirmed).not.toBeNull();
expect(events.confirmed.updateId).toBe(events.applied.updateId);
// Should NOT have rollback event (success case)
expect(events.rolledBack).toBeNull();
});
test('should handle optimistic updates with form inputs', async ({ page }) => {
// Type in input (optimistic)
const input = page.locator('input[data-lc-model="title"][data-optimistic="true"]');
await input.fill('Optimistic Title');
// Display should update immediately
const titleDisplay = page.locator('[data-title-display]');
await expect(titleDisplay).toHaveText('Optimistic Title');
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Value should persist after confirmation
await expect(titleDisplay).toHaveText('Optimistic Title');
});
test('should rollback form input on validation error', async ({ page }) => {
const initialValue = await page.locator('[data-title-display]').textContent();
// Mock validation error
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 422,
body: JSON.stringify({
success: false,
errors: { title: 'Title is too short' }
})
});
});
// Type invalid value (optimistic)
const input = page.locator('input[data-lc-model="title"][data-optimistic="true"]');
await input.fill('AB');
// Display updates optimistically
await expect(page.locator('[data-title-display]')).toHaveText('AB');
// Wait for validation error
await page.waitForTimeout(1000);
// Should rollback to previous value
await expect(page.locator('[data-title-display]')).toHaveText(initialValue);
// Should show validation error
const errorMessage = page.locator('.validation-error');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).toContainText('too short');
});
test('should handle optimistic list item addition', async ({ page }) => {
const initialCount = await page.locator('.list-item').count();
// Add item optimistically
await page.click('[data-lc-action="addItem"][data-optimistic="true"]');
// Item should appear immediately
await expect(page.locator('.list-item')).toHaveCount(initialCount + 1);
// New item should have pending indicator
const newItem = page.locator('.list-item').nth(initialCount);
await expect(newItem).toHaveClass(/optimistic-pending/);
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Pending indicator should be removed
await expect(newItem).not.toHaveClass(/optimistic-pending/);
});
test('should rollback list item addition on failure', async ({ page }) => {
const initialCount = await page.locator('.list-item').count();
// Mock server error
await page.route('**/livecomponent/**', async (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Failed to add item' })
});
});
// Add item optimistically
await page.click('[data-lc-action="addItem"][data-optimistic="true"]');
// Item appears immediately
await expect(page.locator('.list-item')).toHaveCount(initialCount + 1);
// Wait for error and rollback
await page.waitForTimeout(1500);
// Item should be removed (rollback)
await expect(page.locator('.list-item')).toHaveCount(initialCount);
// Error notification
const errorNotification = page.locator('.optimistic-error');
await expect(errorNotification).toBeVisible();
});
test('should handle optimistic toggle state', async ({ page }) => {
const toggle = page.locator('[data-lc-action="toggleActive"][data-optimistic="true"]');
const statusDisplay = page.locator('[data-status]');
// Get initial state
const initialStatus = await statusDisplay.textContent();
// Toggle optimistically
await toggle.click();
// Status should change immediately
const optimisticStatus = await statusDisplay.textContent();
expect(optimisticStatus).not.toBe(initialStatus);
// Wait for server confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Status should persist
const confirmedStatus = await statusDisplay.textContent();
expect(confirmedStatus).toBe(optimisticStatus);
});
test('should show loading spinner during optimistic update confirmation', async ({ page }) => {
// Click optimistic action
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// Loading spinner should appear
const loadingSpinner = page.locator('.optimistic-loading');
await expect(loadingSpinner).toBeVisible();
// Wait for confirmation
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Spinner should disappear
await expect(loadingSpinner).not.toBeVisible();
});
test('should preserve optimistic updates across page visibility changes', async ({ page }) => {
// Apply optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
const optimisticValue = await page.locator('[data-counter]').textContent();
// Hide page (simulate tab switch)
await page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(500);
// Show page again
await page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
// Optimistic value should still be there
const currentValue = await page.locator('[data-counter]').textContent();
expect(currentValue).toBe(optimisticValue);
});
test('should handle network timeout during optimistic update confirmation', async ({ page }) => {
// Mock slow server (timeout)
await page.route('**/livecomponent/**', async (route) => {
await new Promise(resolve => setTimeout(resolve, 10000)); // 10s delay
route.continue();
});
const initialValue = await page.locator('[data-counter]').textContent();
// Apply optimistic update
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// UI updates immediately
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && counter.textContent !== expected;
}, initialValue);
// Wait for timeout (should be < 10s)
await page.waitForTimeout(5000);
// Should show timeout error
const errorNotification = page.locator('.optimistic-timeout');
await expect(errorNotification).toBeVisible();
// Should offer retry option
const retryButton = page.locator('[data-action="retry-optimistic"]');
await expect(retryButton).toBeVisible();
});
test('should batch multiple optimistic updates correctly', async ({ page }) => {
const initialValue = await page.locator('[data-counter]').textContent();
const initialCount = parseInt(initialValue);
// Apply multiple updates rapidly (should batch)
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
await page.click('[data-lc-action="increment"][data-optimistic="true"]');
// All updates applied optimistically
await page.waitForFunction((expected) => {
const counter = document.querySelector('[data-counter]');
return counter && parseInt(counter.textContent) === expected;
}, initialCount + 5);
// Wait for batched server confirmation
await page.waitForLoadState('networkidle');
// Final value should be correct
const finalValue = await page.locator('[data-counter]').textContent();
expect(parseInt(finalValue)).toBe(initialCount + 5);
});
});

View File

@@ -0,0 +1,361 @@
/**
* LiveComponents E2E Tests - Partial Rendering & Fragments
*
* Tests for fragment-based partial rendering functionality:
* - Fragment extraction and rendering
* - DOM patching with focus preservation
* - Multiple fragment updates in single request
* - Error handling and fallback to full render
*
* @see src/Framework/LiveComponents/Services/FragmentExtractor.php
* @see src/Framework/LiveComponents/Services/FragmentRenderer.php
* @see resources/js/modules/DomPatcher.js
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - Partial Rendering & Fragments', () => {
test.beforeEach(async ({ page }) => {
// Navigate to test page with LiveComponent
await page.goto('/livecomponents/test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should render initial component with fragments', async ({ page }) => {
// Check that component has fragment markers
const headerFragment = await page.locator('[data-lc-fragment="header"]');
const contentFragment = await page.locator('[data-lc-fragment="content"]');
const footerFragment = await page.locator('[data-lc-fragment="footer"]');
await expect(headerFragment).toBeVisible();
await expect(contentFragment).toBeVisible();
await expect(footerFragment).toBeVisible();
});
test('should update single fragment without full re-render', async ({ page }) => {
// Get initial content
const contentFragment = page.locator('[data-lc-fragment="content"]');
const initialContent = await contentFragment.textContent();
// Get component ID
const componentId = await page.locator('[data-component-id]').getAttribute('data-component-id');
// Trigger action that updates only content fragment
await page.click('[data-lc-action="updateContent"]');
// Wait for fragment update
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Content should have changed
const updatedContent = await contentFragment.textContent();
expect(updatedContent).not.toBe(initialContent);
// Header and footer should remain unchanged (no re-render)
const headerElement = page.locator('[data-lc-fragment="header"]');
const footerElement = page.locator('[data-lc-fragment="footer"]');
// Elements should still have same references (not re-created)
await expect(headerElement).toBeVisible();
await expect(footerElement).toBeVisible();
});
test('should update multiple fragments in single request', async ({ page }) => {
const headerFragment = page.locator('[data-lc-fragment="header"]');
const contentFragment = page.locator('[data-lc-fragment="content"]');
const initialHeader = await headerFragment.textContent();
const initialContent = await contentFragment.textContent();
// Trigger action that updates both header and content
await page.click('[data-lc-action="updateHeaderAndContent"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Both fragments should be updated
const updatedHeader = await headerFragment.textContent();
const updatedContent = await contentFragment.textContent();
expect(updatedHeader).not.toBe(initialHeader);
expect(updatedContent).not.toBe(initialContent);
});
test('should preserve focus during fragment updates', async ({ page }) => {
// Find input in content fragment
const input = page.locator('[data-lc-fragment="content"] input[name="search"]');
await input.fill('test query');
await input.focus();
// Verify focus
await expect(input).toBeFocused();
// Trigger fragment update
await page.click('[data-lc-action="updateContent"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Focus should be preserved
await expect(input).toBeFocused();
// Value should be preserved
await expect(input).toHaveValue('test query');
});
test('should preserve selection state during fragment updates', async ({ page }) => {
const textarea = page.locator('[data-lc-fragment="content"] textarea');
await textarea.fill('Hello World');
// Select portion of text
await textarea.evaluate((el) => {
el.setSelectionRange(0, 5); // Select "Hello"
});
// Trigger fragment update
await page.click('[data-lc-action="updateContent"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Selection should be preserved
const selectionStart = await textarea.evaluate((el) => el.selectionStart);
const selectionEnd = await textarea.evaluate((el) => el.selectionEnd);
expect(selectionStart).toBe(0);
expect(selectionEnd).toBe(5);
});
test('should fall back to full render if fragment not found', async ({ page }) => {
const component = page.locator('[data-component-id]');
const initialHtml = await component.innerHTML();
// Trigger action with non-existent fragment request
await page.evaluate(() => {
window.LiveComponent.executeAction(
document.querySelector('[data-component-id]').dataset.componentId,
'updateContent',
{},
['non-existent-fragment']
);
});
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Should fall back to full render
const updatedHtml = await component.innerHTML();
expect(updatedHtml).not.toBe(initialHtml);
// All fragments should still be present
await expect(page.locator('[data-lc-fragment="header"]')).toBeVisible();
await expect(page.locator('[data-lc-fragment="content"]')).toBeVisible();
await expect(page.locator('[data-lc-fragment="footer"]')).toBeVisible();
});
test('should handle nested fragments correctly', async ({ page }) => {
// Component with nested structure:
// <div data-lc-fragment="parent">
// <div data-lc-fragment="child">...</div>
// </div>
const parentFragment = page.locator('[data-lc-fragment="parent"]');
const childFragment = page.locator('[data-lc-fragment="child"]');
const initialParent = await parentFragment.textContent();
const initialChild = await childFragment.textContent();
// Update only child fragment
await page.click('[data-lc-action="updateChild"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Child should be updated
const updatedChild = await childFragment.textContent();
expect(updatedChild).not.toBe(initialChild);
// Parent container should remain (DOM patching, not replacement)
await expect(parentFragment).toBeVisible();
});
test('should update attributes during fragment patch', async ({ page }) => {
const contentFragment = page.locator('[data-lc-fragment="content"]');
// Check initial class
const initialClass = await contentFragment.getAttribute('class');
// Trigger action that changes fragment attributes
await page.click('[data-lc-action="toggleActive"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Class should be updated
const updatedClass = await contentFragment.getAttribute('class');
expect(updatedClass).not.toBe(initialClass);
expect(updatedClass).toContain('active');
});
test('should handle fragment updates with different element counts', async ({ page }) => {
const listFragment = page.locator('[data-lc-fragment="list"]');
// Initial list with 3 items
let items = await listFragment.locator('li').count();
expect(items).toBe(3);
// Add item
await page.click('[data-lc-action="addItem"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Should have 4 items now
items = await listFragment.locator('li').count();
expect(items).toBe(4);
// Remove item
await page.click('[data-lc-action="removeItem"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Should have 3 items again
items = await listFragment.locator('li').count();
expect(items).toBe(3);
});
test('should preserve event listeners during fragment updates', async ({ page }) => {
// Click counter in fragment
const button = page.locator('[data-lc-fragment="content"] button[data-click-counter]');
const counter = page.locator('[data-lc-fragment="content"] .click-count');
// Click button
await button.click();
await expect(counter).toHaveText('1');
// Trigger fragment update
await page.click('[data-lc-action="updateContent"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Click button again - event listener should still work
await button.click();
await expect(counter).toHaveText('2');
});
test('should handle rapid fragment updates without conflicts', async ({ page }) => {
const contentFragment = page.locator('[data-lc-fragment="content"]');
// Trigger multiple updates rapidly
await page.click('[data-lc-action="updateContent"]');
await page.click('[data-lc-action="updateContent"]');
await page.click('[data-lc-action="updateContent"]');
// Wait for all responses
await page.waitForLoadState('networkidle');
// Fragment should be stable and visible
await expect(contentFragment).toBeVisible();
// Should have processed all updates
const updateCount = await contentFragment.locator('.update-count').textContent();
expect(parseInt(updateCount)).toBeGreaterThanOrEqual(3);
});
test('should emit fragment update events', async ({ page }) => {
let fragmentUpdateEvents = [];
// Listen for fragment update events
await page.exposeFunction('onFragmentUpdate', (event) => {
fragmentUpdateEvents.push(event);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:fragment-updated', (e) => {
window.onFragmentUpdate(e.detail);
});
});
// Trigger fragment update
await page.click('[data-lc-action="updateContent"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
// Wait for event
await page.waitForTimeout(100);
// Should have received fragment update event
expect(fragmentUpdateEvents.length).toBeGreaterThan(0);
expect(fragmentUpdateEvents[0]).toHaveProperty('fragmentName');
expect(fragmentUpdateEvents[0].fragmentName).toBe('content');
});
test('should work with fragments containing special characters', async ({ page }) => {
// Fragment with special characters in content
const fragment = page.locator('[data-lc-fragment="special-chars"]');
const initialContent = await fragment.innerHTML();
// Update fragment
await page.click('[data-lc-action="updateSpecialChars"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const updatedContent = await fragment.innerHTML();
// Should properly handle HTML entities and special characters
expect(updatedContent).not.toBe(initialContent);
expect(updatedContent).toContain('&lt;'); // Escaped <
expect(updatedContent).toContain('&amp;'); // Escaped &
});
test('should handle fragments with dynamic data-attributes', async ({ page }) => {
const fragment = page.locator('[data-lc-fragment="dynamic"]');
const initialDataValue = await fragment.getAttribute('data-custom-value');
// Update fragment with new data attributes
await page.click('[data-lc-action="updateDynamic"]');
await page.waitForResponse(response =>
response.url().includes('/livecomponent') &&
response.status() === 200
);
const updatedDataValue = await fragment.getAttribute('data-custom-value');
expect(updatedDataValue).not.toBe(initialDataValue);
});
});

View File

@@ -0,0 +1,476 @@
/**
* LiveComponents E2E Tests - SSE Real-time Updates
*
* Tests for Server-Sent Events (SSE) real-time component updates:
* - SSE connection establishment
* - Real-time component updates
* - Connection state management
* - Automatic reconnection
* - Event stream parsing
* - Multiple concurrent SSE connections
*
* @see src/Framework/LiveComponents/Services/SseUpdateService.php
* @see resources/js/modules/LiveComponent.js (SSE handling)
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents - SSE Real-time Updates', () => {
test.beforeEach(async ({ page }) => {
// Navigate to SSE test page
await page.goto('/livecomponents/sse-test');
// Wait for LiveComponent to be initialized
await page.waitForFunction(() => window.LiveComponent !== undefined);
});
test('should establish SSE connection on component mount', async ({ page }) => {
// Wait for SSE connection to be established
await page.waitForFunction(() => {
const component = document.querySelector('[data-component-id]');
return component && component.dataset.sseConnected === 'true';
}, { timeout: 5000 });
// Verify connection indicator
const connectionStatus = page.locator('.sse-connection-status');
await expect(connectionStatus).toHaveClass(/connected|active/);
await expect(connectionStatus).toContainText(/connected|online/i);
});
test('should receive and apply real-time updates', async ({ page }) => {
// Get initial value
const initialValue = await page.locator('[data-sse-value]').textContent();
// Trigger server-side update via API
await page.evaluate(async () => {
await fetch('/api/trigger-sse-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ componentId: 'test-component', newValue: 'Updated via SSE' })
});
});
// Wait for SSE update to be applied
await page.waitForFunction(() => {
const element = document.querySelector('[data-sse-value]');
return element && element.textContent !== 'Updated via SSE';
}, { timeout: 5000 });
const updatedValue = await page.locator('[data-sse-value]').textContent();
expect(updatedValue).not.toBe(initialValue);
expect(updatedValue).toBe('Updated via SSE');
});
test('should parse different SSE event types correctly', async ({ page }) => {
const receivedEvents = [];
// Monitor SSE events
await page.exposeFunction('onSseEvent', (event) => {
receivedEvents.push(event);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:sse-message', (e) => {
window.onSseEvent(e.detail);
});
});
// Trigger various event types
await page.evaluate(async () => {
await fetch('/api/trigger-sse-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
events: [
{ type: 'component-update', data: { field: 'title', value: 'New Title' } },
{ type: 'notification', data: { message: 'New message' } },
{ type: 'state-sync', data: { state: { counter: 42 } } }
]
})
});
});
// Wait for events
await page.waitForTimeout(1000);
// Should have received 3 events
expect(receivedEvents.length).toBeGreaterThanOrEqual(3);
// Verify event types
const eventTypes = receivedEvents.map(e => e.type);
expect(eventTypes).toContain('component-update');
expect(eventTypes).toContain('notification');
expect(eventTypes).toContain('state-sync');
});
test('should show connection lost indicator on disconnect', async ({ page }) => {
// Close SSE connection programmatically
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
const componentId = component.dataset.componentId;
window.LiveComponent.closeSseConnection(componentId);
});
// Connection status should show disconnected
const connectionStatus = page.locator('.sse-connection-status');
await expect(connectionStatus).toHaveClass(/disconnected|offline/);
await expect(connectionStatus).toContainText(/disconnected|offline/i);
});
test('should automatically reconnect after connection loss', async ({ page }) => {
// Simulate connection loss
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
const componentId = component.dataset.componentId;
window.LiveComponent.closeSseConnection(componentId);
});
// Wait for disconnect
await page.waitForSelector('.sse-connection-status.disconnected');
// Should automatically attempt reconnection
await page.waitForFunction(() => {
const component = document.querySelector('[data-component-id]');
return component && component.dataset.sseConnected === 'true';
}, { timeout: 10000 });
// Should be reconnected
const connectionStatus = page.locator('.sse-connection-status');
await expect(connectionStatus).toHaveClass(/connected|active/);
});
test('should handle SSE reconnection with exponential backoff', async ({ page }) => {
const reconnectAttempts = [];
// Monitor reconnection attempts
await page.exposeFunction('onReconnectAttempt', (attempt) => {
reconnectAttempts.push(attempt);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:sse-reconnect-attempt', (e) => {
window.onReconnectAttempt({
timestamp: Date.now(),
attemptNumber: e.detail.attemptNumber,
delay: e.detail.delay
});
});
});
// Simulate connection loss
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
const componentId = component.dataset.componentId;
window.LiveComponent.closeSseConnection(componentId);
});
// Wait for multiple reconnection attempts
await page.waitForTimeout(5000);
// Should have multiple attempts
expect(reconnectAttempts.length).toBeGreaterThan(1);
// Delays should increase (exponential backoff)
if (reconnectAttempts.length >= 2) {
expect(reconnectAttempts[1].delay).toBeGreaterThan(reconnectAttempts[0].delay);
}
});
test('should handle multiple SSE connections for different components', async ({ page }) => {
// Create multiple components
await page.evaluate(() => {
const container = document.querySelector('#component-container');
for (let i = 1; i <= 3; i++) {
const component = document.createElement('div');
component.dataset.componentId = `test-component-${i}`;
component.dataset.componentName = 'TestComponent';
component.dataset.sseEnabled = 'true';
component.innerHTML = `<span data-sse-value>Component ${i}</span>`;
container.appendChild(component);
}
});
// Initialize SSE for all components
await page.evaluate(() => {
window.LiveComponent.initializeAllComponents();
});
// Wait for all connections
await page.waitForFunction(() => {
const components = document.querySelectorAll('[data-sse-enabled="true"]');
return Array.from(components).every(c => c.dataset.sseConnected === 'true');
}, { timeout: 10000 });
// All components should have active SSE connections
const connectedComponents = page.locator('[data-sse-connected="true"]');
await expect(connectedComponents).toHaveCount(3);
});
test('should update only target component on SSE message', async ({ page }) => {
// Create two components
await page.evaluate(() => {
const container = document.querySelector('#component-container');
['component-1', 'component-2'].forEach(id => {
const component = document.createElement('div');
component.dataset.componentId = id;
component.dataset.componentName = 'TestComponent';
component.dataset.sseEnabled = 'true';
component.innerHTML = `<span data-value>Initial Value</span>`;
container.appendChild(component);
});
window.LiveComponent.initializeAllComponents();
});
// Wait for connections
await page.waitForTimeout(1000);
// Trigger update for component-1 only
await page.evaluate(async () => {
await fetch('/api/trigger-sse-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
componentId: 'component-1',
newValue: 'Updated Value'
})
});
});
await page.waitForTimeout(1000);
// Component 1 should be updated
const component1Value = await page.locator('#component-1 [data-value]').textContent();
expect(component1Value).toBe('Updated Value');
// Component 2 should remain unchanged
const component2Value = await page.locator('#component-2 [data-value]').textContent();
expect(component2Value).toBe('Initial Value');
});
test('should handle SSE heartbeat/keep-alive messages', async ({ page }) => {
const heartbeats = [];
// Monitor heartbeat messages
await page.exposeFunction('onHeartbeat', (timestamp) => {
heartbeats.push(timestamp);
});
await page.evaluate(() => {
window.addEventListener('livecomponent:sse-heartbeat', () => {
window.onHeartbeat(Date.now());
});
});
// Wait for multiple heartbeats (typically every 15-30 seconds)
await page.waitForTimeout(35000);
// Should have received at least 1 heartbeat
expect(heartbeats.length).toBeGreaterThanOrEqual(1);
});
test('should emit SSE lifecycle events', async ({ page }) => {
const events = {
opened: null,
message: [],
error: null,
closed: null
};
await page.exposeFunction('onSseOpen', (event) => {
events.opened = event;
});
await page.exposeFunction('onSseMessage', (event) => {
events.message.push(event);
});
await page.exposeFunction('onSseError', (event) => {
events.error = event;
});
await page.exposeFunction('onSseClose', (event) => {
events.closed = event;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:sse-open', (e) => {
window.onSseOpen(e.detail);
});
window.addEventListener('livecomponent:sse-message', (e) => {
window.onSseMessage(e.detail);
});
window.addEventListener('livecomponent:sse-error', (e) => {
window.onSseError(e.detail);
});
window.addEventListener('livecomponent:sse-close', (e) => {
window.onSseClose(e.detail);
});
});
// Wait for connection and messages
await page.waitForTimeout(2000);
// Verify open event
expect(events.opened).not.toBeNull();
expect(events.opened).toHaveProperty('componentId');
// Simulate close
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
window.LiveComponent.closeSseConnection(component.dataset.componentId);
});
await page.waitForTimeout(500);
// Verify close event
expect(events.closed).not.toBeNull();
});
test('should handle SSE errors gracefully', async ({ page }) => {
// Mock SSE endpoint to return error
await page.route('**/livecomponent/sse/**', async (route) => {
route.fulfill({
status: 500,
body: 'Internal Server Error'
});
});
// Try to establish connection
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
window.LiveComponent.initializeSse(component.dataset.componentId);
});
// Should show error state
const connectionStatus = page.locator('.sse-connection-status');
await expect(connectionStatus).toHaveClass(/error|failed/);
// Should display error notification
const errorNotification = page.locator('.sse-error-notification');
await expect(errorNotification).toBeVisible();
});
test('should batch SSE updates to prevent UI thrashing', async ({ page }) => {
const renderCount = [];
// Monitor component renders
await page.exposeFunction('onComponentRender', (timestamp) => {
renderCount.push(timestamp);
});
await page.evaluate(() => {
const originalRender = window.LiveComponent.renderComponent;
window.LiveComponent.renderComponent = function(...args) {
window.onComponentRender(Date.now());
return originalRender.apply(this, args);
};
});
// Send rapid SSE updates
await page.evaluate(async () => {
for (let i = 0; i < 10; i++) {
await fetch('/api/trigger-sse-update', {
method: 'POST',
body: JSON.stringify({ value: `Update ${i}` })
});
}
});
await page.waitForTimeout(2000);
// Should have batched renders (less than 10)
expect(renderCount.length).toBeLessThan(10);
});
test('should allow disabling SSE per component', async ({ page }) => {
// Create component without SSE
await page.evaluate(() => {
const container = document.querySelector('#component-container');
const component = document.createElement('div');
component.dataset.componentId = 'no-sse-component';
component.dataset.componentName = 'TestComponent';
// No data-sse-enabled attribute
component.innerHTML = '<span>Static Component</span>';
container.appendChild(component);
window.LiveComponent.initializeAllComponents();
});
await page.waitForTimeout(1000);
// Component should NOT have SSE connection
const component = page.locator('[data-component-id="no-sse-component"]');
await expect(component).not.toHaveAttribute('data-sse-connected');
// Connection status should not exist
const connectionStatus = component.locator('.sse-connection-status');
await expect(connectionStatus).not.toBeVisible();
});
test('should close SSE connection on component unmount', async ({ page }) => {
let closeEventReceived = false;
await page.exposeFunction('onSseClose', () => {
closeEventReceived = true;
});
await page.evaluate(() => {
window.addEventListener('livecomponent:sse-close', () => {
window.onSseClose();
});
});
// Remove component from DOM
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
component.remove();
});
await page.waitForTimeout(500);
// Should have closed SSE connection
expect(closeEventReceived).toBe(true);
});
test('should handle SSE authentication/authorization', async ({ page }) => {
// Mock SSE endpoint requiring authentication
await page.route('**/livecomponent/sse/**', async (route) => {
const headers = route.request().headers();
if (!headers['authorization']) {
route.fulfill({
status: 401,
body: 'Unauthorized'
});
} else {
route.continue();
}
});
// Set auth token
await page.evaluate(() => {
window.LiveComponent.setSseAuthToken('Bearer test-token-123');
});
// Initialize SSE
await page.evaluate(() => {
const component = document.querySelector('[data-component-id]');
window.LiveComponent.initializeSse(component.dataset.componentId);
});
// Should successfully connect with auth
await page.waitForFunction(() => {
const component = document.querySelector('[data-component-id]');
return component && component.dataset.sseConnected === 'true';
}, { timeout: 5000 });
const connectionStatus = page.locator('.sse-connection-status');
await expect(connectionStatus).toHaveClass(/connected|active/);
});
});

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);
});
});
});

View File

@@ -0,0 +1,148 @@
import { Page, expect } from '@playwright/test';
/**
* Helper utilities for Playwright E2E tests
*/
/**
* Wait for LiveComponent to be ready
*/
export async function waitForLiveComponent(page: Page, componentId: string) {
await page.waitForSelector(`[data-live-component="${componentId}"]`, {
state: 'visible',
timeout: 5000
});
}
/**
* Wait for HTMX request to complete
*/
export async function waitForHtmxRequest(page: Page) {
await page.waitForEvent('htmx:afterRequest', { timeout: 5000 });
}
/**
* Fill form and submit with LiveComponent validation
*/
export async function fillAndSubmitForm(
page: Page,
formSelector: string,
data: Record<string, string>
) {
const form = page.locator(formSelector);
for (const [name, value] of Object.entries(data)) {
await form.locator(`[name="${name}"]`).fill(value);
}
await form.locator('button[type="submit"]').click();
}
/**
* Assert validation error is shown
*/
export async function assertValidationError(
page: Page,
fieldName: string,
expectedError?: string
) {
const errorSelector = `[data-field="${fieldName}"] .error-message, .error-${fieldName}`;
await expect(page.locator(errorSelector)).toBeVisible();
if (expectedError) {
await expect(page.locator(errorSelector)).toContainText(expectedError);
}
}
/**
* Assert no validation errors
*/
export async function assertNoValidationErrors(page: Page) {
const errors = page.locator('.error-message, [class*="error-"]');
await expect(errors).toHaveCount(0);
}
/**
* Wait for success message
*/
export async function waitForSuccessMessage(page: Page, message?: string) {
const successSelector = '.flash-success, .alert-success, [role="alert"][data-type="success"]';
await expect(page.locator(successSelector)).toBeVisible();
if (message) {
await expect(page.locator(successSelector)).toContainText(message);
}
}
/**
* Login helper for authenticated tests
*/
export async function login(page: Page, email: string, password: string) {
await page.goto('/login');
await page.fill('[name="email"]', email);
await page.fill('[name="password"]', password);
await page.click('button[type="submit"]');
// Wait for redirect after login
await page.waitForURL('**/dashboard', { timeout: 5000 });
}
/**
* Logout helper
*/
export async function logout(page: Page) {
await page.click('[data-action="logout"]');
await page.waitForURL('**/login', { timeout: 5000 });
}
/**
* Check if element is visible in viewport
*/
export async function isInViewport(page: Page, selector: string): Promise<boolean> {
return await page.locator(selector).evaluate((element) => {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
});
}
/**
* Scroll element into view
*/
export async function scrollIntoView(page: Page, selector: string) {
await page.locator(selector).scrollIntoViewIfNeeded();
}
/**
* Upload file helper
*/
export async function uploadFile(
page: Page,
inputSelector: string,
filePath: string
) {
const fileInput = page.locator(inputSelector);
await fileInput.setInputFiles(filePath);
}
/**
* Wait for network idle
*/
export async function waitForNetworkIdle(page: Page) {
await page.waitForLoadState('networkidle', { timeout: 10000 });
}
/**
* Clear browser storage
*/
export async function clearStorage(page: Page) {
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
}