- 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.
513 lines
19 KiB
JavaScript
513 lines
19 KiB
JavaScript
/**
|
|
* DomPatcher Tests
|
|
*
|
|
* Tests for lightweight DOM patching functionality used by LiveComponent
|
|
* for efficient fragment-based partial rendering.
|
|
*/
|
|
|
|
import { DomPatcher, domPatcher } from '../../../../resources/js/modules/livecomponent/DomPatcher.js';
|
|
|
|
describe('DomPatcher', () => {
|
|
let patcher;
|
|
|
|
beforeEach(() => {
|
|
patcher = new DomPatcher();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
describe('patchFragment', () => {
|
|
it('patches a single fragment successfully', () => {
|
|
// Setup container with fragment
|
|
const container = document.createElement('div');
|
|
container.innerHTML = `
|
|
<div data-lc-fragment="user-stats">
|
|
<h3>Statistics</h3>
|
|
<p>Count: 5</p>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(container);
|
|
|
|
// Patch fragment with new content
|
|
const newHtml = `
|
|
<div data-lc-fragment="user-stats">
|
|
<h3>Statistics</h3>
|
|
<p>Count: 10</p>
|
|
</div>
|
|
`;
|
|
|
|
const result = patcher.patchFragment(container, 'user-stats', newHtml);
|
|
|
|
expect(result).toBe(true);
|
|
expect(container.querySelector('[data-lc-fragment="user-stats"] p').textContent).toBe('Count: 10');
|
|
});
|
|
|
|
it('returns false when fragment not found', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div>No fragments here</div>';
|
|
|
|
const result = patcher.patchFragment(container, 'nonexistent', '<div data-lc-fragment="nonexistent">Test</div>');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false for invalid HTML', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div data-lc-fragment="test">Test</div>';
|
|
|
|
const result = patcher.patchFragment(container, 'test', '');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false when fragment name mismatch', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div data-lc-fragment="fragment-a">Test</div>';
|
|
|
|
const newHtml = '<div data-lc-fragment="fragment-b">Updated</div>';
|
|
const result = patcher.patchFragment(container, 'fragment-a', newHtml);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('patchFragments', () => {
|
|
it('patches multiple fragments at once', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = `
|
|
<div data-lc-fragment="header">
|
|
<h1>Old Header</h1>
|
|
</div>
|
|
<div data-lc-fragment="content">
|
|
<p>Old Content</p>
|
|
</div>
|
|
<div data-lc-fragment="footer">
|
|
<p>Old Footer</p>
|
|
</div>
|
|
`;
|
|
|
|
const fragments = {
|
|
'header': '<div data-lc-fragment="header"><h1>New Header</h1></div>',
|
|
'content': '<div data-lc-fragment="content"><p>New Content</p></div>'
|
|
};
|
|
|
|
const results = patcher.patchFragments(container, fragments);
|
|
|
|
expect(results.header).toBe(true);
|
|
expect(results.content).toBe(true);
|
|
expect(container.querySelector('[data-lc-fragment="header"] h1').textContent).toBe('New Header');
|
|
expect(container.querySelector('[data-lc-fragment="content"] p').textContent).toBe('New Content');
|
|
expect(container.querySelector('[data-lc-fragment="footer"] p').textContent).toBe('Old Footer');
|
|
});
|
|
|
|
it('returns success status for each fragment', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div data-lc-fragment="exists">Test</div>';
|
|
|
|
const fragments = {
|
|
'exists': '<div data-lc-fragment="exists">Updated</div>',
|
|
'missing': '<div data-lc-fragment="missing">Not found</div>'
|
|
};
|
|
|
|
const results = patcher.patchFragments(container, fragments);
|
|
|
|
expect(results.exists).toBe(true);
|
|
expect(results.missing).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('patchAttributes', () => {
|
|
it('updates changed attributes', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.setAttribute('class', 'old-class');
|
|
oldElement.setAttribute('data-value', '5');
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.setAttribute('class', 'new-class');
|
|
newElement.setAttribute('data-value', '10');
|
|
|
|
patcher.patchAttributes(oldElement, newElement);
|
|
|
|
expect(oldElement.getAttribute('class')).toBe('new-class');
|
|
expect(oldElement.getAttribute('data-value')).toBe('10');
|
|
});
|
|
|
|
it('adds new attributes', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.setAttribute('class', 'test');
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.setAttribute('class', 'test');
|
|
newElement.setAttribute('data-new', 'value');
|
|
|
|
patcher.patchAttributes(oldElement, newElement);
|
|
|
|
expect(oldElement.getAttribute('data-new')).toBe('value');
|
|
});
|
|
|
|
it('removes attributes that no longer exist', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.setAttribute('class', 'test');
|
|
oldElement.setAttribute('data-old', 'value');
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.setAttribute('class', 'test');
|
|
|
|
patcher.patchAttributes(oldElement, newElement);
|
|
|
|
expect(oldElement.hasAttribute('data-old')).toBe(false);
|
|
expect(oldElement.getAttribute('class')).toBe('test');
|
|
});
|
|
|
|
it('does not update unchanged attributes', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.setAttribute('class', 'unchanged');
|
|
const setAttributeSpy = jest.spyOn(oldElement, 'setAttribute');
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.setAttribute('class', 'unchanged');
|
|
|
|
patcher.patchAttributes(oldElement, newElement);
|
|
|
|
expect(setAttributeSpy).not.toHaveBeenCalledWith('class', 'unchanged');
|
|
});
|
|
});
|
|
|
|
describe('patchChildren', () => {
|
|
it('appends new child nodes', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.innerHTML = '<p>First</p>';
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.innerHTML = '<p>First</p><p>Second</p>';
|
|
|
|
patcher.patchChildren(oldElement, newElement);
|
|
|
|
expect(oldElement.children.length).toBe(2);
|
|
expect(oldElement.children[1].textContent).toBe('Second');
|
|
});
|
|
|
|
it('removes deleted child nodes', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.innerHTML = '<p>First</p><p>Second</p>';
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.innerHTML = '<p>First</p>';
|
|
|
|
patcher.patchChildren(oldElement, newElement);
|
|
|
|
expect(oldElement.children.length).toBe(1);
|
|
expect(oldElement.children[0].textContent).toBe('First');
|
|
});
|
|
|
|
it('updates text node content', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.textContent = 'Old text';
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.textContent = 'New text';
|
|
|
|
patcher.patchChildren(oldElement, newElement);
|
|
|
|
expect(oldElement.textContent).toBe('New text');
|
|
});
|
|
|
|
it('recursively patches nested elements', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.innerHTML = '<div><p>Old</p></div>';
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.innerHTML = '<div><p>New</p></div>';
|
|
|
|
patcher.patchChildren(oldElement, newElement);
|
|
|
|
expect(oldElement.querySelector('p').textContent).toBe('New');
|
|
});
|
|
|
|
it('replaces elements with different tags', () => {
|
|
const oldElement = document.createElement('div');
|
|
oldElement.innerHTML = '<p>Content</p>';
|
|
|
|
const newElement = document.createElement('div');
|
|
newElement.innerHTML = '<span>Content</span>';
|
|
|
|
patcher.patchChildren(oldElement, newElement);
|
|
|
|
expect(oldElement.children[0].tagName).toBe('SPAN');
|
|
});
|
|
});
|
|
|
|
describe('shouldPatch', () => {
|
|
it('returns false for different node types', () => {
|
|
const textNode = document.createTextNode('text');
|
|
const elementNode = document.createElement('div');
|
|
|
|
expect(patcher.shouldPatch(textNode, elementNode)).toBe(false);
|
|
});
|
|
|
|
it('returns true for text nodes', () => {
|
|
const oldText = document.createTextNode('old');
|
|
const newText = document.createTextNode('new');
|
|
|
|
expect(patcher.shouldPatch(oldText, newText)).toBe(true);
|
|
});
|
|
|
|
it('returns false for different element tags', () => {
|
|
const divElement = document.createElement('div');
|
|
const spanElement = document.createElement('span');
|
|
|
|
expect(patcher.shouldPatch(divElement, spanElement)).toBe(false);
|
|
});
|
|
|
|
it('returns true for same element tags', () => {
|
|
const div1 = document.createElement('div');
|
|
const div2 = document.createElement('div');
|
|
|
|
expect(patcher.shouldPatch(div1, div2)).toBe(true);
|
|
});
|
|
|
|
it('respects data-lc-key for element identity', () => {
|
|
const element1 = document.createElement('div');
|
|
element1.setAttribute('data-lc-key', 'key-1');
|
|
|
|
const element2 = document.createElement('div');
|
|
element2.setAttribute('data-lc-key', 'key-2');
|
|
|
|
expect(patcher.shouldPatch(element1, element2)).toBe(false);
|
|
});
|
|
|
|
it('patches elements with matching data-lc-key', () => {
|
|
const element1 = document.createElement('div');
|
|
element1.setAttribute('data-lc-key', 'same-key');
|
|
|
|
const element2 = document.createElement('div');
|
|
element2.setAttribute('data-lc-key', 'same-key');
|
|
|
|
expect(patcher.shouldPatch(element1, element2)).toBe(true);
|
|
});
|
|
|
|
it('uses id as fallback key', () => {
|
|
const element1 = document.createElement('div');
|
|
element1.id = 'element-1';
|
|
|
|
const element2 = document.createElement('div');
|
|
element2.id = 'element-2';
|
|
|
|
expect(patcher.shouldPatch(element1, element2)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('preserveFocus', () => {
|
|
it('preserves focused input element', () => {
|
|
const container = document.createElement('div');
|
|
const input = document.createElement('input');
|
|
input.id = 'test-input';
|
|
input.value = 'Test';
|
|
container.appendChild(input);
|
|
document.body.appendChild(container);
|
|
|
|
input.focus();
|
|
input.setSelectionRange(2, 4);
|
|
|
|
const restoreFocus = patcher.preserveFocus(container);
|
|
|
|
// Simulate patching that might lose focus
|
|
document.body.focus();
|
|
|
|
restoreFocus();
|
|
|
|
expect(document.activeElement).toBe(input);
|
|
expect(input.selectionStart).toBe(2);
|
|
expect(input.selectionEnd).toBe(4);
|
|
});
|
|
|
|
it('returns no-op when no element is focused', () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
|
|
const restoreFocus = patcher.preserveFocus(container);
|
|
|
|
expect(typeof restoreFocus).toBe('function');
|
|
expect(() => restoreFocus()).not.toThrow();
|
|
});
|
|
|
|
it('returns no-op when focused element is outside container', () => {
|
|
const container = document.createElement('div');
|
|
const outsideInput = document.createElement('input');
|
|
document.body.appendChild(container);
|
|
document.body.appendChild(outsideInput);
|
|
|
|
outsideInput.focus();
|
|
|
|
const restoreFocus = patcher.preserveFocus(container);
|
|
|
|
expect(typeof restoreFocus).toBe('function');
|
|
expect(() => restoreFocus()).not.toThrow();
|
|
});
|
|
|
|
it('handles focus restoration failure gracefully', () => {
|
|
const container = document.createElement('div');
|
|
const input = document.createElement('input');
|
|
input.name = 'test';
|
|
container.appendChild(input);
|
|
document.body.appendChild(container);
|
|
|
|
input.focus();
|
|
|
|
const restoreFocus = patcher.preserveFocus(container);
|
|
|
|
// Remove the input before restoration
|
|
container.removeChild(input);
|
|
|
|
expect(() => restoreFocus()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getElementSelector', () => {
|
|
it('uses id as primary selector', () => {
|
|
const element = document.createElement('div');
|
|
element.id = 'unique-id';
|
|
element.name = 'test-name';
|
|
|
|
expect(patcher.getElementSelector(element)).toBe('#unique-id');
|
|
});
|
|
|
|
it('uses name as fallback selector', () => {
|
|
const element = document.createElement('input');
|
|
element.name = 'field-name';
|
|
|
|
expect(patcher.getElementSelector(element)).toBe('[name="field-name"]');
|
|
});
|
|
|
|
it('uses data-lc-key as selector', () => {
|
|
const element = document.createElement('div');
|
|
element.setAttribute('data-lc-key', 'item-123');
|
|
|
|
expect(patcher.getElementSelector(element)).toBe('[data-lc-key="item-123"]');
|
|
});
|
|
|
|
it('falls back to tag name', () => {
|
|
const element = document.createElement('span');
|
|
|
|
expect(patcher.getElementSelector(element)).toBe('span');
|
|
});
|
|
});
|
|
|
|
describe('singleton instance', () => {
|
|
it('exports singleton domPatcher instance', () => {
|
|
expect(domPatcher).toBeInstanceOf(DomPatcher);
|
|
});
|
|
|
|
it('singleton is the same instance', () => {
|
|
const instance1 = domPatcher;
|
|
const instance2 = domPatcher;
|
|
|
|
expect(instance1).toBe(instance2);
|
|
});
|
|
});
|
|
|
|
describe('complex patching scenarios', () => {
|
|
it('patches nested fragments with multiple changes', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = `
|
|
<div data-lc-fragment="card">
|
|
<h2 class="old-title">Old Title</h2>
|
|
<div class="content">
|
|
<p>Old paragraph 1</p>
|
|
<p>Old paragraph 2</p>
|
|
</div>
|
|
<button data-action="old">Old Button</button>
|
|
</div>
|
|
`;
|
|
|
|
const newHtml = `
|
|
<div data-lc-fragment="card">
|
|
<h2 class="new-title">New Title</h2>
|
|
<div class="content">
|
|
<p>New paragraph 1</p>
|
|
<p>New paragraph 2</p>
|
|
<p>New paragraph 3</p>
|
|
</div>
|
|
<button data-action="new">New Button</button>
|
|
</div>
|
|
`;
|
|
|
|
patcher.patchFragment(container, 'card', newHtml);
|
|
|
|
const fragment = container.querySelector('[data-lc-fragment="card"]');
|
|
expect(fragment.querySelector('h2').textContent).toBe('New Title');
|
|
expect(fragment.querySelector('h2').className).toBe('new-title');
|
|
expect(fragment.querySelectorAll('.content p').length).toBe(3);
|
|
expect(fragment.querySelector('button').getAttribute('data-action')).toBe('new');
|
|
});
|
|
|
|
it('handles whitespace and formatting changes', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div data-lc-fragment="test"><p>Content</p></div>';
|
|
|
|
const newHtml = `
|
|
<div data-lc-fragment="test">
|
|
<p>Content</p>
|
|
</div>
|
|
`;
|
|
|
|
const result = patcher.patchFragment(container, 'test', newHtml);
|
|
|
|
expect(result).toBe(true);
|
|
expect(container.querySelector('p').textContent).toBe('Content');
|
|
});
|
|
|
|
it('preserves event listeners on patched elements', () => {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = '<div data-lc-fragment="test"><button id="btn">Click</button></div>';
|
|
document.body.appendChild(container);
|
|
|
|
const button = container.querySelector('#btn');
|
|
let clicked = false;
|
|
button.addEventListener('click', () => { clicked = true; });
|
|
|
|
const newHtml = '<div data-lc-fragment="test"><button id="btn">Updated</button></div>';
|
|
patcher.patchFragment(container, 'test', newHtml);
|
|
|
|
// Event listener is preserved because element is patched, not replaced
|
|
container.querySelector('#btn').click();
|
|
expect(clicked).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('performance characteristics', () => {
|
|
it('patches large fragment efficiently', () => {
|
|
const container = document.createElement('div');
|
|
const itemCount = 100;
|
|
|
|
// Build initial list
|
|
const items = [];
|
|
for (let i = 0; i < itemCount; i++) {
|
|
items.push(`<li data-lc-key="item-${i}">Item ${i}</li>`);
|
|
}
|
|
container.innerHTML = `<ul data-lc-fragment="list">${items.join('')}</ul>`;
|
|
|
|
// Update half the items
|
|
const updatedItems = [];
|
|
for (let i = 0; i < itemCount; i++) {
|
|
if (i % 2 === 0) {
|
|
updatedItems.push(`<li data-lc-key="item-${i}">Updated Item ${i}</li>`);
|
|
} else {
|
|
updatedItems.push(`<li data-lc-key="item-${i}">Item ${i}</li>`);
|
|
}
|
|
}
|
|
const newHtml = `<ul data-lc-fragment="list">${updatedItems.join('')}</ul>`;
|
|
|
|
const startTime = performance.now();
|
|
patcher.patchFragment(container, 'list', newHtml);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should complete in reasonable time (< 100ms for 100 items)
|
|
expect(duration).toBeLessThan(100);
|
|
|
|
// Verify updates applied
|
|
const firstItem = container.querySelector('[data-lc-key="item-0"]');
|
|
expect(firstItem.textContent).toBe('Updated Item 0');
|
|
});
|
|
});
|
|
});
|