/** * 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: //
//
...
//
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('<'); // Escaped < expect(updatedContent).toContain('&'); // 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); }); });