Repository: capricorn86/happy-dom (ts)

Issue:
Add support for node.normalize()
https://developer.mozilla.org/en-US/docs/Web/API/Node/normalize

Pull request description:
#300@minor: Add Node.normalize()
fix #300

- Add method `Node.normalize`
- Add helper `NodeUtility.isTextNode` with type assertion
- Add unit tests for `Node.normalize` and `NodeUtility.isTextNode`
- Fix typo in `NodeUtility.nextDescendantNode` method name

Patch:
diff --git a/packages/happy-dom/src/nodes/node/INode.ts b/packages/happy-dom/src/nodes/node/INode.ts
index f0c2c314..52a5f766 100644
--- a/packages/happy-dom/src/nodes/node/INode.ts
+++ b/packages/happy-dom/src/nodes/node/INode.ts
@@ -47,4 +47,5 @@ export default interface INode extends IEventTarget {
 	replaceChild(newChild: INode, oldChild: INode): INode;
 	toString(): string;
 	compareDocumentPosition(otherNode: INode): number;
+	normalize(): void;
 }
diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts
index 0d0c5a95..e2b05dc4 100644
--- a/packages/happy-dom/src/nodes/node/Node.ts
+++ b/packages/happy-dom/src/nodes/node/Node.ts
@@ -788,6 +788,36 @@ export default class Node extends EventTarget implements INode {
 			: Node.DOCUMENT_POSITION_FOLLOWING;
 	}
 
+	/**
+	 * Normalizes the sub-tree of the node, i.e. joins adjacent text nodes, and
+	 * removes all empty text nodes.
+	 *
+	 * @see https://developer.mozilla.org/en-US/docs/Web/API/Node/normalize
+	 */
+	public normalize(): void {
+		let child = this.firstChild;
+		while (child) {
+			if (NodeUtility.isTextNode(child)) {
+				// Append text of all following text nodes, and remove them.
+				while (NodeUtility.isTextNode(child.nextSibling)) {
+					child.data += child.nextSibling.data;
+					child.nextSibling.remove();
+				}
+				// Remove text node if it is still empty.
+				if (!child.data.length) {
+					const node = child;
+					child = child.nextSibling;
+					node.remove();
+					continue;
+				}
+			} else {
+				// Normalize child nodes recursively.
+				child.normalize();
+			}
+			child = child.nextSibling;
+		}
+	}
+
 	/**
 	 * This will be called by JSON.stringify() when serializing this node.
 	 *
diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts
index 6da4c590..32f30be6 100644
--- a/packages/happy-dom/src/nodes/node/NodeUtility.ts
+++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts
@@ -11,6 +11,16 @@ import IProcessingInstruction from '../processing-instruction/IProcessingInstruc
  * Node utility.
  */
 export default class NodeUtility {
+	/**
+	 * Returns whether the passed node is a text node, and narrows its type.
+	 *
+	 * @param node The node to be tested.
+	 * @returns "true" if the node is a text node.
+	 */
+	public static isTextNode(node: INode | null): node is IText {
+		return node?.nodeType === NodeTypeEnum.textNode;
+	}
+
 	/**
 	 * Returns boolean indicating if nodeB is an inclusive ancestor of nodeA.
 	 *
@@ -127,9 +137,9 @@ export default class NodeUtility {
 	 * Returns the next sibling or parents sibling.
 	 *
 	 * @param node Node.
-	 * @returns Next decentant node.
+	 * @returns Next descendant node.
 	 */
-	public static nextDecendantNode(node: INode): INode {
+	public static nextDescendantNode(node: INode): INode {
 		while (node && !node.nextSibling) {
 			node = node.parentNode;
 		}
diff --git a/packages/happy-dom/src/range/Range.ts b/packages/happy-dom/src/range/Range.ts
index 88eecd7b..a0eed489 100644
--- a/packages/happy-dom/src/range/Range.ts
+++ b/packages/happy-dom/src/range/Range.ts
@@ -434,7 +434,7 @@ export default class Range {
 
 		const nodesToRemove = [];
 		let currentNode = this._start.node;
-		const endNode = NodeUtility.nextDecendantNode(this._end.node);
+		const endNode = NodeUtility.nextDescendantNode(this._end.node);
 		while (currentNode && currentNode !== endNode) {
 			if (
 				RangeUtility.isContained(currentNode, this) &&
@@ -973,7 +973,7 @@ export default class Range {
 	 */
 	public surroundContents(newParent: INode): void {
 		let node = this.commonAncestorContainer;
-		const endNode = NodeUtility.nextDecendantNode(node);
+		const endNode = NodeUtility.nextDescendantNode(node);
 		while (node !== endNode) {
 			if (
 				node.nodeType !== NodeTypeEnum.textNode &&
@@ -1030,7 +1030,7 @@ export default class Range {
 			string += (<IText>this._start.node).data.slice(startOffset);
 		}
 
-		const endNode = NodeUtility.nextDecendantNode(this._end.node);
+		const endNode = NodeUtility.nextDescendantNode(this._end.node);
 		let currentNode = this._start.node;
 
 		while (currentNode && currentNode !== endNode) {


Test patch:
diff --git a/packages/happy-dom/test/nodes/node/Node.test.ts b/packages/happy-dom/test/nodes/node/Node.test.ts
index ab8c6c70..d7ebb495 100644
--- a/packages/happy-dom/test/nodes/node/Node.test.ts
+++ b/packages/happy-dom/test/nodes/node/Node.test.ts
@@ -2,6 +2,7 @@ import Window from '../../../src/window/Window';
 import Node from '../../../src/nodes/node/Node';
 import HTMLElement from '../../../src/nodes/html-element/HTMLElement';
 import Event from '../../../src/event/Event';
+import Text from '../../../src/nodes/text/Text';
 
 /**
  *
@@ -709,4 +710,64 @@ describe('Node', () => {
 			expect(position).toEqual(10);
 		});
 	});
+
+	describe('normalize()', () => {
+		it('Normalizes an element.', () => {
+			const txt = document.createTextNode.bind(document);
+			const div = document.createElement('div');
+			const span = document.createElement('span');
+			span.append(txt('sp'), txt('an'));
+			const b = document.createElement('b');
+			b.append(txt(''), txt(''), txt(''));
+			div.append(txt(''), txt('d'), txt(''), txt('i'), txt('v'), span, txt(''), b, txt(''));
+			expect(div.childNodes).toHaveLength(9);
+			div.normalize();
+			expect(div.childNodes).toHaveLength(3);
+			expect(div.childNodes[0]).toBeInstanceOf(Text);
+			expect(div.childNodes[0].nodeValue).toBe('div');
+			expect(div.childNodes[1]).toBe(span);
+			expect(div.childNodes[2]).toBe(b);
+			expect(span.childNodes).toHaveLength(1);
+			expect(span.childNodes[0]).toBeInstanceOf(Text);
+			expect(span.childNodes[0].nodeValue).toBe('span');
+			expect(b.childNodes).toHaveLength(0);
+		});
+
+		it('Normalizes a document fragment.', () => {
+			const txt = document.createTextNode.bind(document);
+			const fragment = document.createDocumentFragment();
+			const span = document.createElement('span');
+			span.append(txt('sp'), txt('an'));
+			const b = document.createElement('b');
+			b.append(txt(''), txt(''), txt(''));
+			fragment.append(txt(''), txt('d'), txt(''), txt('i'), txt('v'), span, txt(''), b, txt(''));
+			expect(fragment.childNodes).toHaveLength(9);
+			fragment.normalize();
+			expect(fragment.childNodes).toHaveLength(3);
+			expect(fragment.childNodes[0]).toBeInstanceOf(Text);
+			expect(fragment.childNodes[0].nodeValue).toBe('div');
+			expect(fragment.childNodes[1]).toBe(span);
+			expect(fragment.childNodes[2]).toBe(b);
+			expect(span.childNodes).toHaveLength(1);
+			expect(span.childNodes[0]).toBeInstanceOf(Text);
+			expect(span.childNodes[0].nodeValue).toBe('span');
+			expect(b.childNodes).toHaveLength(0);
+		});
+
+		it('Normalizes the document.', () => {
+			const count = document.childNodes.length;
+			document.append(document.createTextNode(''));
+			expect(document.childNodes).toHaveLength(count + 1);
+			document.normalize();
+			expect(document.childNodes).toHaveLength(count);
+		});
+
+		it('Does nothing on a text node.', () => {
+			const div = document.createElement('div');
+			const node = div.appendChild(document.createTextNode(''));
+			node.normalize();
+			expect(div.childNodes).toHaveLength(1);
+			expect(div.childNodes[0]).toBe(node);
+		});
+	});
 });
diff --git a/packages/happy-dom/test/nodes/node/NodeUtility.test.ts b/packages/happy-dom/test/nodes/node/NodeUtility.test.ts
index d6f685cf..4e08793a 100644
--- a/packages/happy-dom/test/nodes/node/NodeUtility.test.ts
+++ b/packages/happy-dom/test/nodes/node/NodeUtility.test.ts
@@ -18,6 +18,23 @@ describe('NodeUtility', () => {
 		jest.restoreAllMocks();
 	});
 
+	describe('isTextNode()', () => {
+		it('Returns "true" for text node.', () => {
+			expect(NodeUtility.isTextNode(document.createTextNode('a'))).toBe(true);
+		});
+
+		it('Returns "false" for other nodes.', () => {
+			expect(NodeUtility.isTextNode(document)).toBe(false);
+			expect(NodeUtility.isTextNode(document.createElement('div'))).toBe(false);
+			expect(NodeUtility.isTextNode(document.createDocumentFragment())).toBe(false);
+			expect(NodeUtility.isTextNode(document.createAttribute('attr'))).toBe(false);
+		});
+
+		it('Returns "false" for null.', () => {
+			expect(NodeUtility.isTextNode(null)).toBe(false);
+		});
+	});
+
 	describe('isInclusiveAncestor()', () => {
 		it('Returns "true" if referenceNode is the same as ancestorNode.', () => {
 			const ancestorNode = document.createElement('div');