/** * 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: //