/* * Copyright https://github.com/divio/django-cms * Tests for dom-diff module (native browser API replacement for diff-dom) */ /* global describe, it, expect, beforeEach, afterEach */ 'use strict'; import { DiffDOM, nodeToObj } from '../../../static/cms/js/modules/dom-diff'; describe('DiffDOM', function() { let dd; let container; beforeEach(function() { dd = new DiffDOM(); container = document.createElement('div'); document.body.appendChild(container); }); afterEach(function() { if (container && container.parentNode) { container.parentNode.removeChild(container); } }); describe('nodeToObj', function() { it('converts a text node to object', function() { const textNode = document.createTextNode('Hello World'); const obj = nodeToObj(textNode); expect(obj.nodeName).toBe('#text'); expect(obj.data).toBe('Hello World'); }); it('converts an element node to object', function() { const div = document.createElement('div'); div.setAttribute('id', 'test'); div.setAttribute('class', 'example'); div.innerHTML = 'Text'; const obj = nodeToObj(div); expect(obj.nodeName).toBe('DIV'); expect(obj.attributes.id).toBe('test'); expect(obj.attributes.class).toBe('example'); expect(obj.childNodes.length).toBe(1); expect(obj.childNodes[0].nodeName).toBe('SPAN'); }); it('converts a comment node to object', function() { var comment = document.createComment(' a comment '); var obj = nodeToObj(comment); expect(obj.nodeName).toBe('#comment'); expect(obj.data).toBe(' a comment '); }); it('preserves comments inside elements', function() { var div = document.createElement('div'); div.appendChild(document.createComment('before')); div.appendChild(document.createElement('p')); div.appendChild(document.createComment('after')); var obj = nodeToObj(div); // All three children should be present, no nulls expect(obj.childNodes.length).toBe(3); expect(obj.childNodes[0].nodeName).toBe('#comment'); expect(obj.childNodes[0].data).toBe('before'); expect(obj.childNodes[1].nodeName).toBe('P'); expect(obj.childNodes[2].nodeName).toBe('#comment'); expect(obj.childNodes[2].data).toBe('after'); }); it('handles nested elements', function() { const div = document.createElement('div'); div.innerHTML = ''; const obj = nodeToObj(div); expect(obj.nodeName).toBe('DIV'); expect(obj.childNodes.length).toBe(1); expect(obj.childNodes[0].nodeName).toBe('UL'); expect(obj.childNodes[0].childNodes.length).toBe(2); }); }); describe('diff and apply', function() { it('applies simple HTML string to empty container', function() { const diff = dd.diff(container, '

Hello

'); dd.apply(container, diff); expect(container.children.length).toBe(1); expect(container.children[0].tagName).toBe('P'); expect(container.children[0].textContent).toBe('Hello'); }); it('replaces existing content with new HTML', function() { container.innerHTML = '

Old content

'; const diff = dd.diff(container, '
New content
'); dd.apply(container, diff); expect(container.children.length).toBe(1); expect(container.children[0].tagName).toBe('SPAN'); expect(container.children[0].textContent).toBe('New content'); }); it('handles multiple child elements', function() { const diff = dd.diff(container, '

First

Second

Third

'); dd.apply(container, diff); expect(container.children.length).toBe(3); expect(container.children[0].textContent).toBe('First'); expect(container.children[1].textContent).toBe('Second'); expect(container.children[2].textContent).toBe('Third'); }); it('preserves attributes when applying diff', function() { const html = '
Link
'; const diff = dd.diff(container, html); dd.apply(container, diff); const link = container.querySelector('a'); expect(link).not.toBeNull(); expect(link.getAttribute('href')).toBe('/test'); expect(link.getAttribute('class')).toBe('link'); expect(link.getAttribute('data-id')).toBe('123'); expect(link.textContent).toBe('Link'); }); it('handles complex nested structures', function() { const html = `

Title

Paragraph 1

Paragraph 2

`; const diff = dd.diff(container, html); dd.apply(container, diff); expect(container.querySelector('.header')).not.toBeNull(); expect(container.querySelector('.content')).not.toBeNull(); expect(container.querySelector('h1').textContent).toBe('Title'); expect(container.querySelectorAll('li').length).toBe(2); expect(container.querySelectorAll('.content p').length).toBe(2); }); it('handles script tags correctly', function() { const html = '

Content

'; const diff = dd.diff(container, html); dd.apply(container, diff); expect(container.querySelector('script')).not.toBeNull(); expect(container.querySelector('p')).not.toBeNull(); }); it('clears container when applying empty content', function() { container.innerHTML = '

Old

Content

'; const diff = dd.diff(container, '
'); dd.apply(container, diff); expect(container.children.length).toBe(0); }); it('works with nodeToObj conversion', function() { const original = document.createElement('div'); original.innerHTML = 'Text content'; const obj = nodeToObj(original); const diff = dd.diff(container, obj); dd.apply(container, diff); expect(container.querySelector('span')).not.toBeNull(); expect(container.querySelector('span').className).toBe('test'); expect(container.querySelector('span').textContent).toBe('Text content'); }); it('preserves comment nodes through round-trip', function() { var original = document.createElement('div'); original.appendChild(document.createComment(' server-rendered ')); original.appendChild(document.createElement('p')); original.appendChild(document.createComment(' end ')); var obj = nodeToObj(original); var diff = dd.diff(container, obj); dd.apply(container, diff); // Comments should survive the nodeToObj -> _objToNode -> apply round-trip expect(container.childNodes.length).toBe(3); expect(container.childNodes[0].nodeType).toBe(Node.COMMENT_NODE); expect(container.childNodes[0].data).toBe(' server-rendered '); expect(container.childNodes[1].nodeName).toBe('P'); expect(container.childNodes[2].nodeType).toBe(Node.COMMENT_NODE); expect(container.childNodes[2].data).toBe(' end '); }); it('preserves text nodes with whitespace', function() { const html = '

Text with spaces

'; const diff = dd.diff(container, html); dd.apply(container, diff); expect(container.querySelector('p').textContent).toBe(' Text with spaces '); }); it('handles special characters in text', function() { const html = '

<script>alert("xss")</script>

'; const diff = dd.diff(container, html); dd.apply(container, diff); expect(container.querySelector('p').textContent).toContain(''); }); }); describe('real-world use cases from cms.structureboard', function() { it('simulates updating a plugin container', function() { // Initial state: empty container const pluginContainer = document.createElement('div'); pluginContainer.className = 'cms-plugin'; document.body.appendChild(pluginContainer); // Simulate server response with new plugin content const serverHTML = `

New Plugin

Updated content from server

`; const diff = dd.diff(pluginContainer, serverHTML); dd.apply(pluginContainer, diff); expect(pluginContainer.querySelector('.cms-plugin-wrapper')).not.toBeNull(); expect(pluginContainer.querySelector('h2').textContent).toBe('New Plugin'); expect(pluginContainer.querySelector('p').textContent).toBe('Updated content from server'); pluginContainer.parentNode.removeChild(pluginContainer); }); it('simulates updating document head metadata', function() { // Create a mock head element const mockHead = document.createElement('div'); mockHead.innerHTML = ` Old Title `; // New head content from server const newHeadObj = { nodeName: 'HEAD', attributes: {}, childNodes: [ { nodeName: 'TITLE', attributes: {}, childNodes: [ { nodeName: '#text', data: 'New Title' } ] }, { nodeName: 'META', attributes: { name: 'description', content: 'New description' }, childNodes: [] }, { nodeName: 'LINK', attributes: { rel: 'stylesheet', href: '/static/new.css' }, childNodes: [] } ] }; const diff = dd.diff(mockHead, newHeadObj); dd.apply(mockHead, diff); expect(mockHead.querySelector('title').textContent).toBe('New Title'); expect(mockHead.querySelector('meta[name="description"]').getAttribute('content')).toBe('New description'); expect(mockHead.querySelector('link[rel="stylesheet"]').getAttribute('href')).toBe('/static/new.css'); }); it('handles forms with input elements', function() { const formHTML = `
`; const diff = dd.diff(container, formHTML); dd.apply(container, diff); const form = container.querySelector('form'); expect(form.getAttribute('action')).toBe('/submit'); expect(form.getAttribute('method')).toBe('post'); const usernameInput = container.querySelector('input[name="username"]'); expect(usernameInput.getAttribute('value')).toBe('testuser'); expect(usernameInput.hasAttribute('required')).toBe(true); const textarea = container.querySelector('textarea'); expect(textarea.textContent).toBe('Default message'); const select = container.querySelector('select'); expect(select.querySelectorAll('option').length).toBe(2); }); }); describe('edge cases and error handling', function() { it('handles null or undefined gracefully', function() { expect(function() { nodeToObj(null); }).not.toThrow(); }); it('handles malformed HTML', function() { // Parser should handle malformed HTML gracefully const malformedHTML = '

Unclosed paragraph

Nested
'; const diff = dd.diff(container, malformedHTML); expect(function() { dd.apply(container, diff); }).not.toThrow(); }); it('does not re-execute identical script elements', function() { // Set up container with a script that sets a global counter var script = document.createElement('script'); script.textContent = 'window.__domDiffTestCounter = (window.__domDiffTestCounter || 0) + 1;'; container.appendChild(script); // Script executed once on initial insert expect(window.__domDiffTestCounter).toBe(1); // Build a new node with an identical script var wrapper = document.createElement('div'); var identicalScript = document.createElement('script'); identicalScript.textContent = 'window.__domDiffTestCounter = (window.__domDiffTestCounter || 0) + 1;'; wrapper.appendChild(identicalScript); var diff = dd.diff(container, nodeToObj(wrapper)); dd.apply(container, diff); // Counter should still be 1 — the identical script was reused, not re-executed expect(window.__domDiffTestCounter).toBe(1); delete window.__domDiffTestCounter; }); it('executes new script elements that differ from existing ones', function() { var script = document.createElement('script'); script.textContent = 'window.__domDiffNewCounter = 1;'; container.appendChild(script); expect(window.__domDiffNewCounter).toBe(1); // Apply diff with a different script var wrapper = document.createElement('div'); var newScript = document.createElement('script'); newScript.textContent = 'window.__domDiffNewCounter = 42;'; wrapper.appendChild(newScript); var diff = dd.diff(container, nodeToObj(wrapper)); dd.apply(container, diff); // New script should have executed expect(window.__domDiffNewCounter).toBe(42); delete window.__domDiffNewCounter; }); it('reuses unchanged nodes (same DOM reference) and removes changed ones', function() { // Set up container with three children var meta = document.createElement('meta'); meta.setAttribute('name', 'description'); meta.setAttribute('content', 'Original'); meta.marker = 'meta'; // Custom property to identify this node var link = document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('href', '/static/style.css'); var title = document.createElement('title'); title.textContent = 'Old Title'; container.appendChild(meta); container.appendChild(link); container.appendChild(title); // Keep references to the original DOM nodes var originalMeta = container.childNodes[0]; var originalLink = container.childNodes[1]; // Build new content: link unchanged, meta changed, title removed, script added var wrapper = document.createElement('div'); var newMeta = document.createElement('meta'); newMeta.setAttribute('name', 'description'); newMeta.setAttribute('content', 'Updated'); wrapper.appendChild(newMeta); var sameLinkCopy = document.createElement('link'); sameLinkCopy.setAttribute('rel', 'stylesheet'); sameLinkCopy.setAttribute('href', '/static/style.css'); wrapper.appendChild(sameLinkCopy); var newScript = document.createElement('script'); newScript.setAttribute('src', '/static/app.js'); wrapper.appendChild(newScript); var diff = dd.diff(container, nodeToObj(wrapper)); dd.apply(container, diff); // Link was unchanged — must be the exact same DOM node (not a clone) expect(container.childNodes.length).toBe(3); expect(container.childNodes[1]).toBe(originalLink); // Meta had its content attribute changed — with recursive diffing the // outer element is reused (shallow match on tag + name attr) and the // attribute is synced in place. expect(container.childNodes[0]).toBe(originalMeta); expect(container.childNodes[0].getAttribute('content')).toBe('Updated'); expect(container.childNodes[0].marker).toBe('meta'); // Custom property preserved // Title was removed expect(container.querySelector('title')).toBeNull(); // Script was added expect(container.querySelector('script')).not.toBeNull(); expect(container.querySelector('script').getAttribute('src')).toBe('/static/app.js'); }); it('removes all unmatched nodes even when keys collide', function() { // Multiple identical whitespace text nodes — a common case in var p1 = document.createElement('p'); p1.textContent = 'Keep'; p1.marker = 'marked'; // Custom property to identify this node var p2 = document.createElement('p'); p2.textContent = 'Remove'; container.appendChild(document.createTextNode('\n')); container.appendChild(p1); container.appendChild(document.createTextNode('\n')); container.appendChild(p2); container.appendChild(document.createTextNode('\n')); var originalP1 = p1; // New content: only one

and two text nodes var wrapper = document.createElement('div'); wrapper.appendChild(document.createTextNode('\n')); var pKeep = document.createElement('p'); pKeep.textContent = 'Keep'; wrapper.appendChild(pKeep); wrapper.appendChild(document.createTextNode('\n')); var diff = dd.diff(container, nodeToObj(wrapper)); dd.apply(container, diff); // Should have exactly 3 children: text, p, text expect(container.childNodes.length).toBe(3); expect(container.childNodes[1].textContent).toBe('Keep'); expect(container.childNodes[1]).toBe(originalP1); // "Remove" paragraph must be gone expect(container.querySelector('p:last-of-type').textContent).toBe('Keep'); expect(container.querySelector('p:last-of-type').marker).toBe('marked'); }); it('does not re-execute nested scripts when outer element changes', function() { // Set up container with a div wrapping a script var wrapper = document.createElement('div'); wrapper.setAttribute('class', 'plugin'); var script = document.createElement('script'); script.textContent = 'window.__nestedScriptCounter = (window.__nestedScriptCounter || 0) + 1;'; wrapper.appendChild(script); var p = document.createElement('p'); p.textContent = 'Old text'; wrapper.appendChild(p); container.appendChild(wrapper); // Script executed once on initial insert expect(window.__nestedScriptCounter).toBe(1); var originalScript = container.querySelector('script'); var originalWrapper = container.firstChild; // Build new content: same div wrapper, same script, but paragraph text changed var newOuter = document.createElement('div'); var newWrapper = document.createElement('div'); newWrapper.setAttribute('class', 'plugin'); var sameScript = document.createElement('script'); sameScript.textContent = 'window.__nestedScriptCounter = (window.__nestedScriptCounter || 0) + 1;'; newWrapper.appendChild(sameScript); var newP = document.createElement('p'); newP.textContent = 'New text'; newWrapper.appendChild(newP); newOuter.appendChild(newWrapper); var diff = dd.diff(container, nodeToObj(newOuter)); dd.apply(container, diff); // Script should NOT have re-executed — counter still 1 expect(window.__nestedScriptCounter).toBe(1); // Script should be the exact same DOM node expect(container.querySelector('script')).toBe(originalScript); // Outer wrapper should be preserved (shallow match, same tag + class synced) expect(container.firstChild).toBe(originalWrapper); // Paragraph text should be updated expect(container.querySelector('p').textContent).toBe('New text'); delete window.__nestedScriptCounter; }); it('preserves outer element when only inner content changes', function() { var outer = document.createElement('div'); outer.setAttribute('class', 'outer'); outer.marker = 'original-outer'; var inner = document.createElement('span'); inner.textContent = 'old'; outer.appendChild(inner); container.appendChild(outer); var originalOuter = container.firstChild; // Build new content: same outer div, different inner text var newRoot = document.createElement('div'); var newOuter = document.createElement('div'); newOuter.setAttribute('class', 'outer'); var newInner = document.createElement('span'); newInner.textContent = 'new'; newOuter.appendChild(newInner); newRoot.appendChild(newOuter); var diff = dd.diff(container, nodeToObj(newRoot)); dd.apply(container, diff); // Outer element should be the same DOM node (preserved via shallow match) expect(container.firstChild).toBe(originalOuter); expect(container.firstChild.marker).toBe('original-outer'); // Inner content should be updated expect(container.querySelector('span').textContent).toBe('new'); }); it('updates attributes on outer element while preserving it', function() { var outer = document.createElement('div'); outer.setAttribute('class', 'old-class'); outer.setAttribute('data-keep', 'yes'); outer.marker = 'same-node'; var inner = document.createElement('p'); inner.textContent = 'content'; outer.appendChild(inner); container.appendChild(outer); var originalOuter = container.firstChild; // Build new content: same div, different class, removed data-keep, added data-new var newRoot = document.createElement('div'); var newOuter = document.createElement('div'); newOuter.setAttribute('class', 'new-class'); newOuter.setAttribute('data-new', 'added'); var newInner = document.createElement('p'); newInner.textContent = 'content'; newOuter.appendChild(newInner); newRoot.appendChild(newOuter); var diff = dd.diff(container, nodeToObj(newRoot)); dd.apply(container, diff); // Same DOM node preserved expect(container.firstChild).toBe(originalOuter); expect(container.firstChild.marker).toBe('same-node'); // Attributes synced expect(container.firstChild.getAttribute('class')).toBe('new-class'); expect(container.firstChild.getAttribute('data-new')).toBe('added'); expect(container.firstChild.getAttribute('data-keep')).toBeNull(); }); it('does not re-execute deeply nested scripts', function() { // Three levels deep: container > div.a > div.b > script var a = document.createElement('div'); a.setAttribute('class', 'a'); var b = document.createElement('div'); b.setAttribute('class', 'b'); var script = document.createElement('script'); script.textContent = 'window.__deepCounter = (window.__deepCounter || 0) + 1;'; b.appendChild(script); a.appendChild(b); container.appendChild(a); expect(window.__deepCounter).toBe(1); var originalScript = container.querySelector('script'); // Build new content: add a sibling to div.b, script unchanged var newRoot = document.createElement('div'); var newA = document.createElement('div'); newA.setAttribute('class', 'a'); var newB = document.createElement('div'); newB.setAttribute('class', 'b'); var sameScript = document.createElement('script'); sameScript.textContent = 'window.__deepCounter = (window.__deepCounter || 0) + 1;'; newB.appendChild(sameScript); var extra = document.createElement('p'); extra.textContent = 'added'; newB.appendChild(extra); newA.appendChild(newB); newRoot.appendChild(newA); var diff = dd.diff(container, nodeToObj(newRoot)); dd.apply(container, diff); // Script must not have re-executed expect(window.__deepCounter).toBe(1); expect(container.querySelector('script')).toBe(originalScript); // New paragraph should be present expect(container.querySelector('p').textContent).toBe('added'); delete window.__deepCounter; }); it('applies plain text string without losing content', function() { // When newContent is a plain string like 'hello', DOMParser produces // a text node as doc.body.firstChild. apply() must handle this and // set the target's textContent rather than clearing it. (#8556 related) container.innerHTML = '

Old

'; var diff = dd.diff(container, 'hello'); dd.apply(container, diff); expect(container.textContent).toBe('hello'); }); it('applies text-node object without losing content', function() { // When newContent is a #text object, _objToNode returns a text node. // apply() must handle this and preserve the text. container.innerHTML = '

Old

'; var diff = dd.diff(container, { nodeName: '#text', data: 'from object' }); dd.apply(container, diff); expect(container.textContent).toBe('from object'); }); it('handles SVG elements', function() { const svgHTML = `
`; const diff = dd.diff(container, svgHTML); dd.apply(container, diff); expect(container.querySelector('svg')).not.toBeNull(); expect(container.querySelector('circle')).not.toBeNull(); }); }); });