- 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.
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
/**
|
|
* 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('<'); // 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);
|
|
});
|
|
});
|