/** * 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('