- 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.
389 lines
15 KiB
JavaScript
389 lines
15 KiB
JavaScript
/**
|
|
* 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('&');
|
|
});
|
|
});
|