From ad3872d4c5841e6ac5cd00969e7b9cd3d46c1a03 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Fri, 27 Dec 2024 02:00:07 +0100 Subject: [PATCH] BREAKING CHANGE: [#1615] Improves XML and HTML parsing (#1617) --- package-lock.json | 2 +- packages/happy-dom/package.json | 1 - packages/happy-dom/src/PropertySymbol.ts | 5 +- .../happy-dom/src/config/HTMLElementConfig.ts | 70 +- .../HTMLElementConfigContentModelEnum.ts | 3 + packages/happy-dom/src/config/NamespaceURI.ts | 2 + .../css/declaration/CSSStyleDeclaration.ts | 5 +- .../CSSStyleDeclarationComputedStyle.ts | 6 +- .../CustomElementReactionStack.ts | 81 + .../custom-element/CustomElementRegistry.ts | 89 +- .../custom-element/CustomElementUtility.ts | 34 + .../ICustomElementDefinition.ts | 16 + .../happy-dom/src/dom-parser/DOMParser.ts | 80 +- packages/happy-dom/src/dom/DOMStringMap.ts | 22 +- packages/happy-dom/src/dom/DOMTokenList.ts | 2 +- .../src/exception/DOMExceptionNameEnum.ts | 3 +- packages/happy-dom/src/history/History.ts | 12 +- .../happy-dom/src/html-parser/HTMLParser.ts | 864 +++++++ .../src/html-serializer/HTMLSerializer.ts | 162 ++ .../happy-dom/src/nodes/document/Document.ts | 255 +- .../happy-dom/src/nodes/element/Element.ts | 143 +- .../src/nodes/element/HTMLCollection.ts | 35 +- .../src/nodes/element/NamedNodeMap.ts | 130 +- .../nodes/element/NamedNodeMapProxyFactory.ts | 27 +- .../html-button-element/HTMLButtonElement.ts | 5 +- .../src/nodes/html-document/HTMLDocument.ts | 60 +- .../src/nodes/html-element/HTMLElement.ts | 132 +- .../HTMLFieldSetElement.ts | 5 +- .../html-form-element/HTMLFormElement.ts | 32 +- .../html-iframe-element/HTMLIFrameElement.ts | 2 +- .../html-input-element/HTMLInputElement.ts | 5 +- .../nodes/html-media-element/TextTrackList.ts | 2 +- .../html-object-element/HTMLObjectElement.ts | 5 +- .../html-output-element/HTMLOutputElement.ts | 5 +- .../html-script-element/HTMLScriptElement.ts | 5 +- .../html-select-element/HTMLSelectElement.ts | 7 +- .../html-table-element/HTMLTableElement.ts | 9 +- .../HTMLTableRowElement.ts | 2 +- .../HTMLTemplateElement.ts | 40 +- .../HTMLTextAreaElement.ts | 5 +- packages/happy-dom/src/nodes/node/Node.ts | 127 +- packages/happy-dom/src/nodes/node/NodeList.ts | 2 +- .../happy-dom/src/nodes/node/NodeUtility.ts | 7 +- .../nodes/parent-node/ParentNodeUtility.ts | 12 +- .../src/nodes/shadow-root/ShadowRoot.ts | 16 +- .../src/nodes/xml-document/XMLDocument.ts | 6 +- .../src/query-selector/SelectorItem.ts | 3 +- packages/happy-dom/src/range/Range.ts | 6 +- packages/happy-dom/src/storage/Storage.ts | 2 +- packages/happy-dom/src/svg/SVGLengthList.ts | 2 +- packages/happy-dom/src/svg/SVGNumberList.ts | 2 +- packages/happy-dom/src/svg/SVGPointList.ts | 2 +- packages/happy-dom/src/svg/SVGStringList.ts | 2 +- .../happy-dom/src/svg/SVGTransformList.ts | 2 +- .../happy-dom/src/tree-walker/NodeIterator.ts | 52 +- .../happy-dom/src/tree-walker/TreeWalker.ts | 12 +- .../src/{ => utilities}/ClassMethodBinder.ts | 0 .../src/{ => utilities}/StringUtility.ts | 0 .../src/utilities/XMLEncodeUtility.ts | 127 + .../happy-dom/src/window/BrowserWindow.ts | 23 +- .../src/window/WindowErrorUtility.ts | 2 +- .../src/xml-http-request/XMLHttpRequest.ts | 4 +- .../XMLHttpRequestResponseDataParser.ts | 2 +- .../happy-dom/src/xml-parser/XMLParser.ts | 940 +++++--- .../src/xml-serializer/XMLSerializer.ts | 262 +- packages/happy-dom/test/CustomElement.ts | 8 + .../CustomElementRegistry.test.ts | 25 +- .../test/dom-parser/DOMParser.test.ts | 212 +- .../test/html-parser/HTMLParser.test.ts | 2108 +++++++++++++++++ .../html-serializer/HTMLSerializer.test.ts | 301 +++ .../MutationObserver.test.ts | 2 +- .../test/nodes/document/Document.test.ts | 147 +- .../test/nodes/element/Element.test.ts | 38 +- .../test/nodes/element/NamedNodeMap.test.ts | 21 + .../nodes/html-element/HTMLElement.test.ts | 4 + .../HTMLIFrameElement.test.ts | 2 +- .../HTMLLabelElement.test.ts | 4 +- .../HTMLScriptElement.test.ts | 131 +- .../HTMLTableElement.test.ts | 65 +- .../HTMLTableRowElement.test.ts | 22 +- .../HTMLTemplateElement.test.ts | 10 +- .../happy-dom/test/nodes/node/Node.test.ts | 49 +- .../test/nodes/node/NodeUtility.test.ts | 21 +- .../test/query-selector/QuerySelector.test.ts | 2 +- .../test/tree-walker/NodeIterator.test.ts | 33 +- .../test/tree-walker/TreeWalker.test.ts | 5 +- .../test/window/BrowserWindow.test.ts | 21 + .../xml-http-request/XMLHttpRequest.test.ts | 6 +- .../test/xml-parser/XMLParser.test.ts | 1268 ++++++---- .../test/xml-parser/data/XMLParserHTML.ts | 27 - .../test/xml-serializer/XMLSerializer.test.ts | 437 ++-- .../test/utilities/TestFunctions.js | 2 +- 92 files changed, 6945 insertions(+), 2011 deletions(-) create mode 100644 packages/happy-dom/src/custom-element/CustomElementReactionStack.ts create mode 100644 packages/happy-dom/src/custom-element/CustomElementUtility.ts create mode 100644 packages/happy-dom/src/custom-element/ICustomElementDefinition.ts create mode 100755 packages/happy-dom/src/html-parser/HTMLParser.ts create mode 100644 packages/happy-dom/src/html-serializer/HTMLSerializer.ts rename packages/happy-dom/src/{ => utilities}/ClassMethodBinder.ts (100%) rename packages/happy-dom/src/{ => utilities}/StringUtility.ts (100%) create mode 100644 packages/happy-dom/src/utilities/XMLEncodeUtility.ts create mode 100644 packages/happy-dom/test/html-parser/HTMLParser.test.ts create mode 100644 packages/happy-dom/test/html-serializer/HTMLSerializer.test.ts delete mode 100644 packages/happy-dom/test/xml-parser/data/XMLParserHTML.ts diff --git a/package-lock.json b/package-lock.json index a57a3c83b..678639bd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4892,6 +4892,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -11798,7 +11799,6 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" }, diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index f7877cc28..9a817ab13 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -77,7 +77,6 @@ "test:debug": "vitest run --inspect-brk --no-file-parallelism" }, "dependencies": { - "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" }, diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index bb5b5e214..32fbe3e4a 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -41,7 +41,6 @@ export const listeners = Symbol('listeners'); export const namedItems = Symbol('namedItems'); export const nextActiveElement = Symbol('nextActiveElement'); export const observeMutations = Symbol('observeMutations'); -export const observedAttributes = Symbol('observedAttributes'); export const mutationListeners = Symbol('mutationListeners'); export const ownerDocument = Symbol('ownerDocument'); export const ownerElement = Symbol('ownerElement'); @@ -375,3 +374,7 @@ export const getLength = Symbol('getLength'); export const currentScale = Symbol('currentScale'); export const rotate = Symbol('rotate'); export const bindMethods = Symbol('bindMethods'); +export const xmlProcessingInstruction = Symbol('xmlProcessingInstruction'); +export const root = Symbol('root'); +export const filterNode = Symbol('filterNode'); +export const customElementReactionStack = Symbol('customElementReactionStack'); diff --git a/packages/happy-dom/src/config/HTMLElementConfig.ts b/packages/happy-dom/src/config/HTMLElementConfig.ts index 27a1e10bb..76fb7d69a 100644 --- a/packages/happy-dom/src/config/HTMLElementConfig.ts +++ b/packages/happy-dom/src/config/HTMLElementConfig.ts @@ -4,7 +4,17 @@ import HTMLElementConfigContentModelEnum from './HTMLElementConfigContentModelEn * @see https://html.spec.whatwg.org/multipage/indices.html */ export default < - { [key: string]: { className: string; contentModel: HTMLElementConfigContentModelEnum } } + { + [key: string]: { + className: string; + contentModel: HTMLElementConfigContentModelEnum; + forbiddenDescendants?: string[]; + permittedDescendants?: string[]; + permittedParents?: string[]; + addPermittedParent?: string; + moveForbiddenDescendant?: { exclude: string[] }; + }; + } >{ a: { className: 'HTMLAnchorElement', @@ -116,7 +126,7 @@ export default < }, caption: { className: 'HTMLTableCaptionElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.textOrComments }, cite: { className: 'HTMLElement', @@ -128,11 +138,13 @@ export default < }, col: { className: 'HTMLTableColElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + permittedParents: ['colgroup'] }, colgroup: { className: 'HTMLTableColElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['col'] }, data: { className: 'HTMLDataElement', @@ -144,7 +156,8 @@ export default < }, dd: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['dt', 'dd'] }, del: { className: 'HTMLModElement', @@ -172,7 +185,8 @@ export default < }, dt: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['dt', 'dd'] }, em: { className: 'HTMLElement', @@ -304,11 +318,12 @@ export default < }, optgroup: { className: 'HTMLOptGroupElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants }, option: { className: 'HTMLOptionElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['option', 'optgroup'] }, output: { className: 'HTMLOutputElement', @@ -344,11 +359,13 @@ export default < }, rp: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['rp', 'rt'] }, rt: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['rp', 'rt'] }, rtc: { className: 'HTMLElement', @@ -404,27 +421,42 @@ export default < }, table: { className: 'HTMLTableElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody'], + moveForbiddenDescendant: { exclude: [] } }, tbody: { className: 'HTMLTableSectionElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['tr'], + permittedParents: ['table'], + moveForbiddenDescendant: { exclude: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody'] } }, td: { className: 'HTMLTableCellElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['td', 'th', 'tr', 'tbody', 'tfoot', 'thead'], + permittedParents: ['tr'] }, tfoot: { className: 'HTMLTableSectionElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['tr'], + permittedParents: ['table'], + moveForbiddenDescendant: { exclude: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody'] } }, th: { className: 'HTMLTableCellElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, + forbiddenDescendants: ['td', 'th', 'tr', 'tbody', 'tfoot', 'thead'], + permittedParents: ['tr'] }, thead: { className: 'HTMLTableSectionElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['tr'], + permittedParents: ['table'], + moveForbiddenDescendant: { exclude: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody'] } }, time: { className: 'HTMLTimeElement', @@ -436,7 +468,11 @@ export default < }, tr: { className: 'HTMLTableRowElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, + permittedDescendants: ['td', 'th'], + permittedParents: ['tbody', 'tfoot', 'thead'], + addPermittedParent: 'tbody', + moveForbiddenDescendant: { exclude: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody', 'tr'] } }, track: { className: 'HTMLTrackElement', diff --git a/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts b/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts index d0c816ae5..1c609f61f 100644 --- a/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +++ b/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts @@ -2,7 +2,10 @@ enum HTMLElementConfigContentModelEnum { rawText = 'rawText', noSelfDescendants = 'noSelfDescendants', noFirstLevelSelfDescendants = 'noFirstLevelSelfDescendants', + noForbiddenFirstLevelDescendants = 'noForbiddenFirstLevelDescendants', noDescendants = 'noDescendants', + permittedDescendants = 'permittedDescendants', + textOrComments = 'textOrComments', anyDescendants = 'anyDescendants' } diff --git a/packages/happy-dom/src/config/NamespaceURI.ts b/packages/happy-dom/src/config/NamespaceURI.ts index 8e57b1559..999d5d59e 100644 --- a/packages/happy-dom/src/config/NamespaceURI.ts +++ b/packages/happy-dom/src/config/NamespaceURI.ts @@ -2,5 +2,7 @@ export default { html: 'http://www.w3.org/1999/xhtml', svg: 'http://www.w3.org/2000/svg', mathML: 'http://www.w3.org/1998/Math/MathML', + xml: 'http://www.w3.org/XML/1998/namespace', + xlink: 'http://www.w3.org/1999/xlink', xmlns: 'http://www.w3.org/2000/xmlns/' }; diff --git a/packages/happy-dom/src/css/declaration/CSSStyleDeclaration.ts b/packages/happy-dom/src/css/declaration/CSSStyleDeclaration.ts index 578a6d7e0..1649e0b5d 100644 --- a/packages/happy-dom/src/css/declaration/CSSStyleDeclaration.ts +++ b/packages/happy-dom/src/css/declaration/CSSStyleDeclaration.ts @@ -4963,10 +4963,7 @@ export default class CSSStyleDeclaration { return new CSSStyleDeclarationComputedStyle(element).getComputedStyle(); } - const attributeValue = - element[PropertySymbol.attributes][PropertySymbol.namedItems].get('style')?.[ - PropertySymbol.value - ]; + const attributeValue = element.getAttribute('style') || ''; if (cache.attributeValue !== attributeValue) { cache.propertyManager = new CSSStyleDeclarationPropertyManager({ cssText: attributeValue }); diff --git a/packages/happy-dom/src/css/declaration/computed-style/CSSStyleDeclarationComputedStyle.ts b/packages/happy-dom/src/css/declaration/computed-style/CSSStyleDeclarationComputedStyle.ts index b78051caf..31159d4a9 100644 --- a/packages/happy-dom/src/css/declaration/computed-style/CSSStyleDeclarationComputedStyle.ts +++ b/packages/happy-dom/src/css/declaration/computed-style/CSSStyleDeclarationComputedStyle.ts @@ -207,12 +207,10 @@ export default class CSSStyleDeclarationComputedStyle { elementCSSText += cssText.cssText; } - const elementStyleAttribute = (parentElement.element)[PropertySymbol.attributes][ - PropertySymbol.namedItems - ].get('style'); + const elementStyleAttribute = (parentElement.element).getAttribute('style'); if (elementStyleAttribute) { - elementCSSText += elementStyleAttribute[PropertySymbol.value]; + elementCSSText += elementStyleAttribute; } const rulesAndProperties = CSSStyleDeclarationCSSParser.parse(elementCSSText); diff --git a/packages/happy-dom/src/custom-element/CustomElementReactionStack.ts b/packages/happy-dom/src/custom-element/CustomElementReactionStack.ts new file mode 100644 index 000000000..31ad292fa --- /dev/null +++ b/packages/happy-dom/src/custom-element/CustomElementReactionStack.ts @@ -0,0 +1,81 @@ +import HTMLElement from '../nodes/html-element/HTMLElement.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import WindowBrowserContext from '../window/WindowBrowserContext.js'; + +/** + * Custom element reaction stack. + * + * @see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions-stack + */ +export default class CustomElementReactionStack { + private window: BrowserWindow; + + /** + * Constructor. + * + * @param window Window. + */ + constructor(window: BrowserWindow) { + this.window = window; + } + + /** + * Enqueues a custom element reaction. + * + * @see https://html.spec.whatwg.org/multipage/custom-elements.html#enqueue-a-custom-element-callback-reaction + * @see https://html.spec.whatwg.org/multipage/custom-elements.html#enqueue-an-element-on-the-appropriate-element-queue + * @param element Element. + * @param callbackName Callback name. + * @param [args] Arguments. + */ + public enqueueReaction(element: HTMLElement, callbackName: string, args?: any[]): void { + // If a polyfill is used, [PropertySymbol.registry] may be undefined + const definition = this.window.customElements[PropertySymbol.registry]?.get(element.localName); + + if (!definition) { + return; + } + + // According to the spec, we should use a queue for each element and then invoke the reactions in the order they were enqueued asynchronously. + // However, the browser seem to always invoke the reactions synchronously. + // TODO: Can we find an example where the reactions are invoked asynchronously? In that case we should use a queue for those cases. + + switch (callbackName) { + case 'connectedCallback': + if (definition.livecycleCallbacks.connectedCallback) { + const returnValue = definition.livecycleCallbacks.connectedCallback.call(element); + + /** + * It is common to import dependencies in the connectedCallback() method of web components. + * As Happy DOM doesn't have support for dynamic imports yet, this is a temporary solution to wait for imports in connectedCallback(). + * + * @see https://github.com/capricorn86/happy-dom/issues/1442 + */ + if (returnValue instanceof Promise) { + const asyncTaskManager = new WindowBrowserContext(this.window).getAsyncTaskManager(); + if (asyncTaskManager) { + const taskID = asyncTaskManager.startTask(); + returnValue + .then(() => asyncTaskManager.endTask(taskID)) + .catch(() => asyncTaskManager.endTask(taskID)); + } + } + } + break; + case 'disconnectedCallback': + if (definition.livecycleCallbacks.disconnectedCallback) { + definition.livecycleCallbacks.disconnectedCallback.call(element); + } + break; + case 'attributeChangedCallback': + if ( + definition.livecycleCallbacks.attributeChangedCallback && + definition.observedAttributes.has(args[0]) + ) { + definition.livecycleCallbacks.attributeChangedCallback.apply(element, args); + } + break; + } + } +} diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index 2cfdf6889..67a038890 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -3,14 +3,15 @@ import HTMLElement from '../nodes/html-element/HTMLElement.js'; import Node from '../nodes/node/Node.js'; import BrowserWindow from '../window/BrowserWindow.js'; import NamespaceURI from '../config/NamespaceURI.js'; +import StringUtility from '../utilities/StringUtility.js'; +import CustomElementUtility from './CustomElementUtility.js'; +import ICustomElementDefinition from './ICustomElementDefinition.js'; /** * Custom elements registry. */ export default class CustomElementRegistry { - public [PropertySymbol.registry]: { - [k: string]: { elementClass: typeof HTMLElement; extends: string }; - } = {}; + public [PropertySymbol.registry]: Map = new Map(); public [PropertySymbol.classRegistry]: Map = new Map(); public [PropertySymbol.callbacks]: Map void>> = new Map(); public [PropertySymbol.destroyed]: boolean = false; @@ -45,13 +46,13 @@ export default class CustomElementRegistry { return; } - if (!this.#isValidCustomElementName(name)) { + if (!CustomElementUtility.isValidCustomElementName(name)) { throw new this.#window.DOMException( `Failed to execute 'define' on 'CustomElementRegistry': "${name}" is not a valid custom element name` ); } - if (this[PropertySymbol.registry][name]) { + if (this[PropertySymbol.registry].has(name)) { throw new this.#window.DOMException( `Failed to execute 'define' on 'CustomElementRegistry': the name "${name}" has already been used with this registry` ); @@ -63,7 +64,7 @@ export default class CustomElementRegistry { ); } - const tagName = name.toUpperCase(); + const tagName = StringUtility.asciiUpperCase(name); elementClass.prototype[PropertySymbol.window] = this.#window; elementClass.prototype[PropertySymbol.ownerDocument] = this.#window.document; @@ -71,17 +72,28 @@ export default class CustomElementRegistry { elementClass.prototype[PropertySymbol.localName] = name; elementClass.prototype[PropertySymbol.namespaceURI] = NamespaceURI.html; - this[PropertySymbol.registry][name] = { + // ObservedAttributes should only be called once by CustomElementRegistry (see #117) + const observedAttributes: Set = new Set(); + const elementObservervedAttributes = elementClass.observedAttributes; + + if (Array.isArray(elementObservervedAttributes)) { + for (const attribute of elementObservervedAttributes) { + observedAttributes.add(String(attribute).toLowerCase()); + } + } + + this[PropertySymbol.registry].set(name, { elementClass, - extends: options && options.extends ? options.extends.toLowerCase() : null - }; + extends: options && options.extends ? options.extends.toLowerCase() : null, + observedAttributes, + livecycleCallbacks: { + connectedCallback: elementClass.prototype.connectedCallback, + disconnectedCallback: elementClass.prototype.disconnectedCallback, + attributeChangedCallback: elementClass.prototype.attributeChangedCallback + } + }); this[PropertySymbol.classRegistry].set(elementClass, name); - // ObservedAttributes should only be called once by CustomElementRegistry (see #117) - elementClass[PropertySymbol.observedAttributes] = (elementClass.observedAttributes || []).map( - (name) => String(name).toLowerCase() - ); - const callbacks = this[PropertySymbol.callbacks].get(name); if (callbacks) { this[PropertySymbol.callbacks].delete(name); @@ -98,7 +110,7 @@ export default class CustomElementRegistry { * @returns HTMLElement Class defined or undefined. */ public get(name: string): typeof HTMLElement | undefined { - return this[PropertySymbol.registry][name]?.elementClass; + return this[PropertySymbol.registry].get(name)?.elementClass; } /** @@ -125,7 +137,7 @@ export default class CustomElementRegistry { ) ); } - if (!this.#isValidCustomElementName(name)) { + if (!CustomElementUtility.isValidCustomElementName(name)) { return Promise.reject( new this.#window.DOMException( `Failed to execute 'whenDefined' on 'CustomElementRegistry': Invalid custom element name: "${name}"` @@ -160,46 +172,15 @@ export default class CustomElementRegistry { */ public [PropertySymbol.destroy](): void { this[PropertySymbol.destroyed] = true; - for (const entity of Object.values(this[PropertySymbol.registry])) { - entity.elementClass.prototype[PropertySymbol.window] = null; - entity.elementClass.prototype[PropertySymbol.ownerDocument] = null; - entity.elementClass.prototype[PropertySymbol.tagName] = null; - entity.elementClass.prototype[PropertySymbol.localName] = null; - entity.elementClass.prototype[PropertySymbol.namespaceURI] = null; + for (const definition of this[PropertySymbol.registry].values()) { + definition.elementClass.prototype[PropertySymbol.window] = null; + definition.elementClass.prototype[PropertySymbol.ownerDocument] = null; + definition.elementClass.prototype[PropertySymbol.tagName] = null; + definition.elementClass.prototype[PropertySymbol.localName] = null; + definition.elementClass.prototype[PropertySymbol.namespaceURI] = null; } - this[PropertySymbol.registry] = {}; + this[PropertySymbol.registry] = new Map(); this[PropertySymbol.classRegistry] = new Map(); this[PropertySymbol.callbacks] = new Map(); } - - /** - * Validates the correctness of custom element tag names. - * - * @param name Custom element tag name. - * @returns True, if tag name is standard compliant. - */ - #isValidCustomElementName(name: string): boolean { - // Validation criteria based on: - // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name - const PCENChar = - '[-_.]|[0-9]|[a-z]|\u{B7}|[\u{C0}-\u{D6}]|[\u{D8}-\u{F6}]' + - '|[\u{F8}-\u{37D}]|[\u{37F}-\u{1FFF}]' + - '|[\u{200C}-\u{200D}]|[\u{203F}-\u{2040}]|[\u{2070}-\u{218F}]' + - '|[\u{2C00}-\u{2FEF}]|[\u{3001}-\u{D7FF}]' + - '|[\u{F900}-\u{FDCF}]|[\u{FDF0}-\u{FFFD}]|[\u{10000}-\u{EFFFF}]'; - - const PCEN = new RegExp(`^[a-z](${PCENChar})*-(${PCENChar})*$`, 'u'); - - const reservedNames = [ - 'annotation-xml', - 'color-profile', - 'font-face', - 'font-face-src', - 'font-face-uri', - 'font-face-format', - 'font-face-name', - 'missing-glyph' - ]; - return PCEN.test(name) && !reservedNames.includes(name); - } } diff --git a/packages/happy-dom/src/custom-element/CustomElementUtility.ts b/packages/happy-dom/src/custom-element/CustomElementUtility.ts new file mode 100644 index 000000000..a24df9175 --- /dev/null +++ b/packages/happy-dom/src/custom-element/CustomElementUtility.ts @@ -0,0 +1,34 @@ +const PCEN_CHAR = + '[-_.]|[0-9]|[a-z]|\u{B7}|[\u{C0}-\u{D6}]|[\u{D8}-\u{F6}]' + + '|[\u{F8}-\u{37D}]|[\u{37F}-\u{1FFF}]' + + '|[\u{200C}-\u{200D}]|[\u{203F}-\u{2040}]|[\u{2070}-\u{218F}]' + + '|[\u{2C00}-\u{2FEF}]|[\u{3001}-\u{D7FF}]' + + '|[\u{F900}-\u{FDCF}]|[\u{FDF0}-\u{FFFD}]|[\u{10000}-\u{EFFFF}]'; + +const PCEN_REGEXP = new RegExp(`^[a-z](${PCEN_CHAR})*-(${PCEN_CHAR})*$`, 'u'); +const RESERVED_NAMES = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph' +]; + +/** + * Custom element utility. + */ +export default class CustomElementUtility { + /** + * Returns true if the tag name is a valid custom element name. + * + * @see https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name + * @param name Tag name. + * @returns True if valid. + */ + public static isValidCustomElementName(name: string): boolean { + return PCEN_REGEXP.test(name) && !RESERVED_NAMES.includes(name); + } +} diff --git a/packages/happy-dom/src/custom-element/ICustomElementDefinition.ts b/packages/happy-dom/src/custom-element/ICustomElementDefinition.ts new file mode 100644 index 000000000..e1b91f600 --- /dev/null +++ b/packages/happy-dom/src/custom-element/ICustomElementDefinition.ts @@ -0,0 +1,16 @@ +import HTMLElement from '../nodes/html-element/HTMLElement.js'; + +export default interface ICustomElementDefinition { + elementClass: typeof HTMLElement; + extends: string; + observedAttributes: Set; + livecycleCallbacks: { + connectedCallback: () => void; + disconnectedCallback: () => void; + attributeChangedCallback: ( + name: string, + oldValue: string | null, + newValue: string | null + ) => void; + }; +} diff --git a/packages/happy-dom/src/dom-parser/DOMParser.ts b/packages/happy-dom/src/dom-parser/DOMParser.ts index 0412ef083..da6cd6c59 100644 --- a/packages/happy-dom/src/dom-parser/DOMParser.ts +++ b/packages/happy-dom/src/dom-parser/DOMParser.ts @@ -1,9 +1,8 @@ import Document from '../nodes/document/Document.js'; import * as PropertySymbol from '../PropertySymbol.js'; import XMLParser from '../xml-parser/XMLParser.js'; -import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; import BrowserWindow from '../window/BrowserWindow.js'; -import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; +import HTMLParser from '../html-parser/HTMLParser.js'; /** * DOM parser. @@ -29,87 +28,18 @@ export default class DOMParser { ); } - const newDocument = this.#createDocument(mimeType); - const documentChildNodes = newDocument[PropertySymbol.nodeArray]; - - while (documentChildNodes.length) { - newDocument.removeChild(documentChildNodes[0]); - } - - const root = XMLParser.parse(newDocument, string, { evaluateScripts: true }); - let documentElement = null; - let documentTypeNode = null; - - for (const node of root[PropertySymbol.nodeArray]) { - if (node['tagName'] === 'HTML') { - documentElement = node; - } else if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { - documentTypeNode = node; - } - - if (documentElement && documentTypeNode) { - break; - } - } - - if (documentElement) { - if (documentTypeNode) { - newDocument.appendChild(documentTypeNode); - } - newDocument.appendChild(documentElement); - const body = newDocument.body; - if (body) { - while (root[PropertySymbol.nodeArray].length) { - body.appendChild(root[PropertySymbol.nodeArray][0]); - } - } - } else { - switch (mimeType) { - case 'image/svg+xml': - { - while (root[PropertySymbol.nodeArray].length) { - newDocument.appendChild(root[PropertySymbol.nodeArray][0]); - } - } - break; - case 'text/html': - default: - { - const documentElement = newDocument.createElement('html'); - const bodyElement = newDocument.createElement('body'); - const headElement = newDocument.createElement('head'); - - documentElement.appendChild(headElement); - documentElement.appendChild(bodyElement); - newDocument.appendChild(documentElement); - - while (root[PropertySymbol.nodeArray].length) { - bodyElement.appendChild(root[PropertySymbol.nodeArray][0]); - } - } - break; - } - } - - return newDocument; - } - - /** - * - * @param mimeType Mime type. - * @returns Document. - */ - #createDocument(mimeType: string): Document { const window = this[PropertySymbol.window]; switch (mimeType) { case 'text/html': - return new window.HTMLDocument(); + const newDocument = new window.HTMLDocument(); + newDocument[PropertySymbol.defaultView] = window; + return new HTMLParser(this[PropertySymbol.window]).parse(string, newDocument); case 'image/svg+xml': case 'text/xml': case 'application/xml': case 'application/xhtml+xml': - return new window.XMLDocument(); + return new XMLParser(this[PropertySymbol.window]).parse(string); default: throw new window.DOMException(`Unknown mime type "${mimeType}".`); } diff --git a/packages/happy-dom/src/dom/DOMStringMap.ts b/packages/happy-dom/src/dom/DOMStringMap.ts index 0790b3f8d..512431bcf 100644 --- a/packages/happy-dom/src/dom/DOMStringMap.ts +++ b/packages/happy-dom/src/dom/DOMStringMap.ts @@ -26,11 +26,11 @@ export default class DOMStringMap { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy return new Proxy(this, { get(_target, property: string): string { - const attribute = element[PropertySymbol.attributes][PropertySymbol.namedItems].get( + const attribute = element.getAttribute( 'data-' + DOMStringMapUtility.camelCaseToKebab(property) ); if (attribute) { - return attribute[PropertySymbol.value]; + return attribute; } }, set(_target, property: string, value: string): boolean { @@ -46,19 +46,21 @@ export default class DOMStringMap { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/ownKeys // "The result List must contain the keys of all non-configurable own properties of the target object." const keys = []; - for (const item of element[PropertySymbol.attributes][PropertySymbol.namedItems].values()) { - if (item[PropertySymbol.name].startsWith('data-')) { + for (const items of element[PropertySymbol.attributes][ + PropertySymbol.namedItems + ].values()) { + if (items[0][PropertySymbol.name].startsWith('data-')) { keys.push( - DOMStringMapUtility.kebabToCamelCase(item[PropertySymbol.name].replace('data-', '')) + DOMStringMapUtility.kebabToCamelCase( + items[0][PropertySymbol.name].replace('data-', '') + ) ); } } return keys; }, has(_target, property: string): boolean { - return element[PropertySymbol.attributes][PropertySymbol.namedItems].has( - 'data-' + DOMStringMapUtility.camelCaseToKebab(property) - ); + return element.hasAttribute('data-' + DOMStringMapUtility.camelCaseToKebab(property)); }, defineProperty(_target, property: string, descriptor): boolean { if (descriptor.value === undefined) { @@ -73,14 +75,14 @@ export default class DOMStringMap { return true; }, getOwnPropertyDescriptor(_target, property: string): PropertyDescriptor { - const attribute = element[PropertySymbol.attributes][PropertySymbol.namedItems].get( + const attribute = element.getAttribute( 'data-' + DOMStringMapUtility.camelCaseToKebab(property) ); if (!attribute) { return; } return { - value: attribute[PropertySymbol.value], + value: attribute, writable: true, enumerable: true, configurable: true diff --git a/packages/happy-dom/src/dom/DOMTokenList.ts b/packages/happy-dom/src/dom/DOMTokenList.ts index 9d3a15513..12ff8add7 100644 --- a/packages/happy-dom/src/dom/DOMTokenList.ts +++ b/packages/happy-dom/src/dom/DOMTokenList.ts @@ -1,4 +1,4 @@ -import ClassMethodBinder from '../ClassMethodBinder.js'; +import ClassMethodBinder from '../utilities/ClassMethodBinder.js'; import Element from '../nodes/element/Element.js'; import * as PropertySymbol from '../PropertySymbol.js'; diff --git a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts index 9f88ec213..b8bf0fdcb 100644 --- a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts +++ b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts @@ -16,6 +16,7 @@ enum DOMExceptionNameEnum { abortError = 'AbortError', timeoutError = 'TimeoutError', encodingError = 'EncodingError', - uriMismatchError = 'URIMismatchError' + uriMismatchError = 'URIMismatchError', + inUseAttributeError = 'InUseAttributeError' } export default DOMExceptionNameEnum; diff --git a/packages/happy-dom/src/history/History.ts b/packages/happy-dom/src/history/History.ts index 18be9e7e6..a4de15ec0 100644 --- a/packages/happy-dom/src/history/History.ts +++ b/packages/happy-dom/src/history/History.ts @@ -47,7 +47,7 @@ export default class History { * @returns History length. */ public get length(): number { - return this.#browserFrame[PropertySymbol.history].length; + return this.#browserFrame?.[PropertySymbol.history].length || 0; } /** @@ -87,7 +87,7 @@ export default class History { */ public back(): void { if (!this.#window.closed) { - this.#browserFrame.goBack(); + this.#browserFrame?.goBack(); } } @@ -96,7 +96,7 @@ export default class History { */ public forward(): void { if (!this.#window.closed) { - this.#browserFrame.goForward(); + this.#browserFrame?.goForward(); } } @@ -108,7 +108,7 @@ export default class History { */ public go(delta: number): void { if (!this.#window.closed) { - this.#browserFrame.goSteps(delta); + this.#browserFrame?.goSteps(delta); } } @@ -124,7 +124,7 @@ export default class History { return; } - const history = this.#browserFrame[PropertySymbol.history]; + const history = this.#browserFrame?.[PropertySymbol.history]; if (!history) { return; @@ -182,7 +182,7 @@ export default class History { return; } - const history = this.#browserFrame[PropertySymbol.history]; + const history = this.#browserFrame?.[PropertySymbol.history]; if (!history) { return; diff --git a/packages/happy-dom/src/html-parser/HTMLParser.ts b/packages/happy-dom/src/html-parser/HTMLParser.ts new file mode 100755 index 000000000..36f109579 --- /dev/null +++ b/packages/happy-dom/src/html-parser/HTMLParser.ts @@ -0,0 +1,864 @@ +import Document from '../nodes/document/Document.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import NamespaceURI from '../config/NamespaceURI.js'; +import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; +import Element from '../nodes/element/Element.js'; +import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; +import Node from '../nodes/node/Node.js'; +import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; +import HTMLElementConfig from '../config/HTMLElementConfig.js'; +import HTMLElementConfigContentModelEnum from '../config/HTMLElementConfigContentModelEnum.js'; +import SVGElementConfig from '../config/SVGElementConfig.js'; +import StringUtility from '../utilities/StringUtility.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import DocumentType from '../nodes/document-type/DocumentType.js'; +import HTMLHeadElement from '../nodes/html-head-element/HTMLHeadElement.js'; +import HTMLBodyElement from '../nodes/html-body-element/HTMLBodyElement.js'; +import HTMLHtmlElement from '../nodes/html-html-element/HTMLHtmlElement.js'; +import XMLEncodeUtility from '../utilities/XMLEncodeUtility.js'; +import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; + +/** + * Markup RegExp. + * + * Group 1: Beginning of start tag (e.g. "div" in ""). + * Group 3: Comment start tag "" + * Group 5: Document type start tag "" in ""). + * Group 8: End of start tag or comment tag (e.g. ">" in "
"). + */ +const MARKUP_REGEXP = /<([^\s/!>?]+)|<\/([^\s/!>?]+)\s*>|(|--!>)|()|(>)/gm; + +/** + * Attribute RegExp. + * + * Group 1: Attribute name when the attribute has a value with no apostrophes (e.g. "name" in "
"). + * Group 2: Attribute value when the attribute has a value with no apostrophes (e.g. "value" in "
"). + * Group 3: Attribute name when the attribute has a value using double apostrophe (e.g. "name" in "
"). + * Group 4: Attribute value when the attribute has a value using double apostrophe (e.g. "value" in "
"). + * Group 5: Attribute end apostrophe when the attribute has a value using double apostrophe (e.g. '"' in "
"). + * Group 6: Attribute name when the attribute has a value using single apostrophe (e.g. "name" in "
"). + * Group 7: Attribute value when the attribute has a value using single apostrophe (e.g. "value" in "
"). + * Group 8: Attribute end apostrophe when the attribute has a value using single apostrophe (e.g. "'" in "
"). + * Group 9: Attribute name when the attribute has no value (e.g. "disabled" in "
"). + */ +const ATTRIBUTE_REGEXP = + /\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*([a-zA-Z0-9-_:.$@?{}/<]+)|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*"([^"]*)("{0,1})|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*'([^']*)('{0,1})|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)/gm; + +/** + * Document type attribute RegExp. + * + * Group 1: Attribute value. + */ +const DOCUMENT_TYPE_ATTRIBUTE_REGEXP = /"([^"]+)"/gm; + +/** + * Space RegExp. + */ +const SPACE_REGEXP = /\s+/; + +/** + * Space in the beginning of string RegExp. + */ +const SPACE_IN_BEGINNING_REGEXP = /^\s+/; + +/** + * Markup read state (which state the parser is in). + */ +enum MarkupReadStateEnum { + any = 'any', + startTag = 'startTag', + comment = 'comment', + documentType = 'documentType', + processingInstruction = 'processingInstruction', + rawTextElement = 'rawTextElement' +} + +/** + * Document type. + */ +interface IDocumentType { + name: string; + publicId: string; + systemId: string; +} + +/** + * How much of the HTML document that has been parsed (where the parser level is). + */ +enum HTMLDocumentStructureLevelEnum { + root = 0, + doctype = 1, + documentElement = 2, + head = 3, + additionalHeadWithoutBody = 4, + body = 5, + afterBody = 6 +} + +interface IHTMLDocumentStructure { + nodes: { + doctype: DocumentType; + documentElement: HTMLHtmlElement; + head: HTMLHeadElement; + body: HTMLBodyElement; + }; + level: HTMLDocumentStructureLevelEnum; +} + +/** + * HTML parser. + */ +export default class HTMLParser { + private window: BrowserWindow; + private evaluateScripts: boolean = false; + private rootNode: Element | DocumentFragment | Document | null = null; + private rootDocument: Document | null = null; + private nodeStack: Node[] = []; + private tagNameStack: string[] = []; + private documentStructure: IHTMLDocumentStructure | null = null; + private startTagIndex = 0; + private markupRegExp: RegExp | null = null; + private nextElement: Element | null = null; + private currentNode: Node | null = null; + private readState: MarkupReadStateEnum = MarkupReadStateEnum.any; + + /** + * Constructor. + * + * @param window Window. + * @param [options] Options. + * @param [options.evaluateScripts] Set to "true" to enable script execution + */ + constructor( + window: BrowserWindow, + options?: { + evaluateScripts?: boolean; + } + ) { + this.window = window; + + if (options?.evaluateScripts) { + this.evaluateScripts = true; + } + } + /** + * Parses HTML a root element containing nodes found. + * + * @param html HTML string. + * @param [rootNode] Root node. + * @returns Root node. + */ + public parse( + html: string, + rootNode?: Element | DocumentFragment | Document + ): Element | DocumentFragment | Document { + this.rootNode = rootNode || this.window.document.createDocumentFragment(); + this.rootDocument = this.rootNode instanceof Document ? this.rootNode : this.window.document; + this.nodeStack = [this.rootNode]; + this.tagNameStack = [null]; + this.currentNode = this.rootNode; + this.readState = MarkupReadStateEnum.any; + this.documentStructure = null; + this.startTagIndex = 0; + this.markupRegExp = new RegExp(MARKUP_REGEXP, 'gm'); + + if (this.rootNode instanceof Document) { + const { doctype, documentElement, head, body } = this.rootNode; + + if (!documentElement || !head || !body) { + throw new Error( + 'Failed to parse HTML: The root node must have "documentElement", "head" and "body".\n\nWe should not end up here and it is therefore a bug in Happy DOM. Please report this issue.' + ); + } + + this.documentStructure = { + nodes: { + doctype: doctype || null, + documentElement, + head, + body + }, + level: HTMLDocumentStructureLevelEnum.root + }; + } + + let match: RegExpExecArray; + let lastIndex = 0; + + html = String(html); + + while ((match = this.markupRegExp.exec(html))) { + switch (this.readState) { + case MarkupReadStateEnum.any: + // Plain text between tags. + if ( + match.index !== lastIndex && + (match[1] || match[2] || match[3] || match[4] || match[5] !== undefined || match[6]) + ) { + this.parsePlainText(html.substring(lastIndex, match.index)); + } + + if (match[1]) { + // Start tag. + this.nextElement = this.getStartTagElement(match[1]); + + this.startTagIndex = this.markupRegExp.lastIndex; + this.readState = MarkupReadStateEnum.startTag; + } else if (match[2]) { + // End tag. + this.parseEndTag(match[2]); + } else if (match[3]) { + // Comment. + this.startTagIndex = this.markupRegExp.lastIndex; + this.readState = MarkupReadStateEnum.comment; + } else if (match[5] !== undefined) { + // Document type. + this.startTagIndex = this.markupRegExp.lastIndex; + this.readState = MarkupReadStateEnum.documentType; + } else if (match[6]) { + // Processing instruction. + this.startTagIndex = this.markupRegExp.lastIndex; + this.readState = MarkupReadStateEnum.processingInstruction; + } else { + // Plain text between tags, including the matched tag as it is not a valid start or end tag. + this.parsePlainText(html.substring(lastIndex, this.markupRegExp.lastIndex)); + } + + break; + case MarkupReadStateEnum.startTag: + // End of start tag + + // match[2] is matching an end tag in case the start tag wasn't closed (e.g. "" instead of "
\n"). + // match[7] is matching "/>" (e.g. ""). + // match[8] is matching ">" (e.g. "
"). + if (match[7] || match[8] || match[2]) { + if (this.nextElement) { + const attributeString = html.substring( + this.startTagIndex, + match[2] ? this.markupRegExp.lastIndex - 1 : match.index + ); + const isSelfClosed = !!match[7]; + + this.parseEndOfStartTag(attributeString, isSelfClosed); + } else { + // If "nextElement" is set to null, the tag is not allowed (, and are not allowed in an HTML fragment or to be nested). + this.readState = MarkupReadStateEnum.any; + } + } + break; + case MarkupReadStateEnum.comment: + // Comment end tag. + + if (match[4]) { + this.parseComment(html.substring(this.startTagIndex, match.index)); + } + break; + case MarkupReadStateEnum.documentType: + // Document type end tag. + + if (match[7] || match[8]) { + this.parseDocumentType(html.substring(this.startTagIndex, match.index)); + } + break; + case MarkupReadStateEnum.processingInstruction: + // Processing instruction end tag. + + if (match[7] || match[8]) { + // Processing instructions are not supported in HTML and are rendered as comments. + this.parseComment('?' + html.substring(this.startTagIndex, match.index)); + } + break; + case MarkupReadStateEnum.rawTextElement: + // End tag of raw text content. + + // - - `.replace(/\s/g, '') + + ` ); }); @@ -94,15 +95,182 @@ describe('DOMParser', () => { '

here is some

html elástica ', 'text/html' ); - // Spurious comment `` should be solved expect(newDocument.body.textContent).toBe('here is some html elástica '); }); - it('parses SVGs', () => { - const newDocument = domParser.parseFromString(DOMParserSVG, 'image/svg+xml'); - expect(new XMLSerializer().serializeToString(newDocument).replace(/[\s]/gm, '')).toBe( - DOMParserSVG.replace(/[\s]/gm, '') + it('Parses SVGs', () => { + const newDocument = domParser.parseFromString( + ` + + + + `, + 'image/svg+xml' + ); + expect(new XMLSerializer().serializeToString(newDocument)) + .toBe(` + + `); + expect(newDocument.documentElement).toBe(newDocument.childNodes[0]); + }); + + it('Parses body', () => { + const newDocument = domParser.parseFromString( + 'Example Text', + 'text/html' + ); + expect(newDocument.body.innerHTML).toBe('Example Text'); + }); + + it('Parses basic XML', () => { + const newDocument = domParser.parseFromString( + ` + + + Belgian Waffles + $5.95 + Two of our famous Belgian Waffles with plenty of real maple syrup + 650 + + + Strawberry Belgian Waffles + $7.95 + Light Belgian waffles covered with strawberries and whipped cream + 900 + + + `, + 'application/xml' + ); + expect(new HTMLSerializer().serializeToString(newDocument)).toBe(` + + Belgian Waffles + $5.95 + Two of our famous Belgian Waffles with plenty of real maple syrup + 650 + + + Strawberry Belgian Waffles + $7.95 + Light Belgian waffles covered with strawberries and whipped cream + 900 + + `); + }); + + it('Parses XML with style tags', () => { + const newDocument = domParser.parseFromString( + ` + + +
+


+
+

This is a test

+

+ +

This has some dynamic generated content

+

This should be enought to test

+
+ +

Absolute paths

+ + + + + +

Relative paths

+ + + + + + + + + +
+
+
`, + 'application/xml' + ); + + expect(newDocument.defaultView).toBe(window); + + expect(new XMLSerializer().serializeToString(newDocument)) + .toBe(` +
+

+
+

This is a test

+

+ +

This has some dynamic generated content

+

This should be enought to test

+
+ +

Absolute paths

+ + + + + +

Relative paths

+ + + + + + + + + +

+
+
`); + }); + + it('Does not call connectedCallback on custom elements as they are not connected to the main document', () => { + /* eslint-disable jsdoc/require-jsdoc */ + class CustomElement extends HTMLElement { + public connectedCount = 0; + public disconnectedCount = 0; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + public connectedCallback(): void { + this.connectedCount++; + } + + public disconnectedCallback(): void { + this.disconnectedCount++; + } + } + + window.customElements.define('custom-element', CustomElement); + + /* eslint-enable jsdoc/require-jsdoc */ + + const newDocument = domParser.parseFromString( + '', + 'text/html' ); + + expect(newDocument.isConnected).toBe(false); + expect(newDocument.defaultView).toBe(window); + + const customElement = newDocument.querySelector('custom-element'); + + expect(customElement.connectedCount).toBe(0); + expect(customElement.disconnectedCount).toBe(0); + + document.body.appendChild(customElement); + + expect(customElement.connectedCount).toBe(1); + expect(customElement.disconnectedCount).toBe(0); }); }); }); diff --git a/packages/happy-dom/test/html-parser/HTMLParser.test.ts b/packages/happy-dom/test/html-parser/HTMLParser.test.ts new file mode 100644 index 000000000..a6b3e7d58 --- /dev/null +++ b/packages/happy-dom/test/html-parser/HTMLParser.test.ts @@ -0,0 +1,2108 @@ +import HTMLParser from '../../src/html-parser/HTMLParser.js'; +import Window from '../../src/window/Window.js'; +import Document from '../../src/nodes/document/Document.js'; +import Node from '../../src/nodes/node/Node.js'; +import HTMLElement from '../../src/nodes/html-element/HTMLElement.js'; +import NamespaceURI from '../../src/config/NamespaceURI.js'; +import DocumentType from '../../src/nodes/document-type/DocumentType.js'; +import HTMLSerializer from '../../src/html-serializer/HTMLSerializer.js'; +import HTMLTemplateElement from '../../src/nodes/html-template-element/HTMLTemplateElement.js'; +import NodeTypeEnum from '../../src/nodes/node/NodeTypeEnum.js'; +import { beforeEach, describe, it, expect } from 'vitest'; +import CustomElement from '../CustomElement.js'; +import HTMLHtmlElement from '../../src/nodes/html-html-element/HTMLHtmlElement.js'; +import XMLSerializer from '../../src/xml-serializer/XMLSerializer.js'; +import TreeWalkerHTML from '../tree-walker/data/TreeWalkerHTML.js'; + +describe('HTMLParser', () => { + let window: Window; + let document: Document; + + beforeEach(() => { + window = new Window(); + document = window.document; + }); + + describe('parse()', () => { + it('Parses HTML with a single
.', () => { + const result = new HTMLParser(window).parse('
'); + expect(result.childNodes.length).toBe(1); + expect(result.childNodes[0].childNodes.length).toBe(0); + expect((result.childNodes[0]).tagName).toBe('DIV'); + }); + + it('Parses HTML with a single
with attributes.', () => { + const result = new HTMLParser(window).parse( + '
' + ); + expect(result.childNodes.length).toBe(1); + expect(result.childNodes[0].childNodes.length).toBe(0); + expect((result.childNodes[0]).tagName).toBe('DIV'); + expect((result.childNodes[0]).id).toBe('id'); + expect((result.childNodes[0]).className).toBe('class1 class2'); + + expect((result.childNodes[0]).attributes.length).toBe(3); + + expect((result.childNodes[0]).attributes[0].name).toBe('class'); + expect((result.childNodes[0]).attributes[0].value).toBe('class1 class2'); + expect((result.childNodes[0]).attributes[0].namespaceURI).toBe(null); + expect((result.childNodes[0]).attributes[0].specified).toBe(true); + expect( + (result.childNodes[0]).attributes[0].ownerElement === result.childNodes[0] + ).toBe(true); + expect((result.childNodes[0]).attributes[0].ownerDocument === document).toBe( + true + ); + + expect((result.childNodes[0]).attributes[1].name).toBe('id'); + expect((result.childNodes[0]).attributes[1].value).toBe('id'); + expect((result.childNodes[0]).attributes[1].namespaceURI).toBe(null); + expect((result.childNodes[0]).attributes[1].specified).toBe(true); + expect( + (result.childNodes[0]).attributes[1].ownerElement === result.childNodes[0] + ).toBe(true); + expect((result.childNodes[0]).attributes[1].ownerDocument === document).toBe( + true + ); + + expect((result.childNodes[0]).attributes[2].name).toBe('data-no-value'); + expect((result.childNodes[0]).attributes[2].value).toBe(''); + expect((result.childNodes[0]).attributes[2].namespaceURI).toBe(null); + expect((result.childNodes[0]).attributes[2].specified).toBe(true); + expect( + (result.childNodes[0]).attributes[2].ownerElement === result.childNodes[0] + ).toBe(true); + expect((result.childNodes[0]).attributes[2].ownerDocument === document).toBe( + true + ); + + expect((result.childNodes[0]).attributes['class'].name).toBe('class'); + expect((result.childNodes[0]).attributes['class'].value).toBe('class1 class2'); + expect((result.childNodes[0]).attributes['class'].namespaceURI).toBe(null); + expect((result.childNodes[0]).attributes['class'].specified).toBe(true); + expect( + (result.childNodes[0]).attributes['class'].ownerElement === + result.childNodes[0] + ).toBe(true); + expect( + (result.childNodes[0]).attributes['class'].ownerDocument === document + ).toBe(true); + + expect((result.childNodes[0]).attributes['id'].name).toBe('id'); + expect((result.childNodes[0]).attributes['id'].value).toBe('id'); + expect((result.childNodes[0]).attributes['id'].namespaceURI).toBe(null); + expect((result.childNodes[0]).attributes['id'].specified).toBe(true); + expect( + (result.childNodes[0]).attributes['id'].ownerElement === result.childNodes[0] + ).toBe(true); + expect((result.childNodes[0]).attributes['id'].ownerDocument === document).toBe( + true + ); + + expect((result.childNodes[0]).attributes['data-no-value'].name).toBe( + 'data-no-value' + ); + expect((result.childNodes[0]).attributes['data-no-value'].value).toBe(''); + expect((result.childNodes[0]).attributes['data-no-value'].namespaceURI).toBe( + null + ); + expect((result.childNodes[0]).attributes['data-no-value'].specified).toBe(true); + expect( + (result.childNodes[0]).attributes['data-no-value'].ownerElement === + result.childNodes[0] + ).toBe(true); + expect( + (result.childNodes[0]).attributes['data-no-value'].ownerDocument === document + ).toBe(true); + }); + + it('Parses an entire HTML page.', () => { + const html = ` + + + + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + + +`; + const expected = ` + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + + +`; + const result = ( + new HTMLParser(window).parse(html, document.implementation.createHTMLDocument()) + ); + expect(result.childNodes[0].ownerDocument).toBe(result); + expect(result.childNodes[1].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[0].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[1].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[2].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[3].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[4].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[5].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[6].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[7].ownerDocument).toBe(result); + expect(result.body.children[0].childNodes[8].ownerDocument).toBe(result); + + expect(new HTMLSerializer().serializeToString(result)).toBe(expected); + }); + + it('Parses a page with document type set to "HTML 4.01".', () => { + const html = ` + + + + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + + +`; + const expected = ` + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + + +`; + + const result = new HTMLParser(window).parse( + html, + document.implementation.createHTMLDocument() + ); + const doctype = result.childNodes[0]; + expect(doctype.name).toBe('html'); + expect(doctype.publicId).toBe('-//W3C//DTD HTML 4.01//EN'); + expect(doctype.systemId).toBe('http://www.w3.org/TR/html4/strict.dtd'); + expect(new HTMLSerializer().serializeToString(result)).toBe(expected); + }); + + it('Handles unnestable elements correctly when there are siblings.', () => { + const result = new HTMLParser(window).parse( + `
+ +
+ Test +
+
+ Test +
` + ); + expect(new HTMLSerializer().serializeToString(result).replace(/\s/gm, '')).toBe( + ` +
+ +
+ Test +
+
+ Test +
+ `.replace(/\s/gm, '') + ); + }); + + it('Handles unnestable elements correctly when the nested element is wrapped by another element.', () => { + const result = new HTMLParser(window).parse( + `
+ +
+ Test +
+
+ Test +
` + ); + expect(new HTMLSerializer().serializeToString(result).replace(/\s/gm, '')).toBe( + ` +
+ +
+ Test +
+
+ Test +
+ `.replace(/\s/gm, '') + ); + }); + + it('Parses a page with document type set to "MathML 1.01".', () => { + const html = ` + + + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + +`; + const expected = ` + Title + + +
+ + + + + Bold + + Span + +
+
+ + Bold + +
+ + + +`; + + const result = new HTMLParser(window).parse( + html, + document.implementation.createHTMLDocument() + ); + const doctype = result.childNodes[0]; + expect(doctype.name).toBe('math'); + expect(doctype.publicId).toBe(''); + expect(doctype.systemId).toBe('http://www.w3.org/Math/DTD/mathml1/mathml.dtd'); + expect(new HTMLSerializer().serializeToString(result)).toBe(expected); + }); + + it('Handles unclosed tags of unnestable elements (e.g. ,
  • ).', () => { + const result = new HTMLParser(window).parse( + ` +
    + + Test +
    + ` + ); + + expect(new HTMLSerializer().serializeToString(result).replace(/\s/gm, '')).toBe( + ` +
    +
      +
    • Test
    • +
    • Test 2
    • +
    • Test 3
    • +
    + Test +
    + `.replace(/\s/gm, '') + ); + }); + + it('Does not parse the content of script and style elements.', () => { + const result = new HTMLParser(window).parse( + `
    + + + +
    ` + ); + + expect((result.children[0].children[0]).innerText).toBe( + `if(11){console.log("1")}` + ); + + expect((result.children[0].children[1]).innerText).toBe(''); + expect((result.children[0].children[2]).innerText).toBe(''); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `
    + + + +
    ` + ); + + const root2 = new HTMLParser(window).parse( + ` + + Title + + + + + `, + document.implementation.createHTMLDocument() + ); + expect((root2.children[0].children[1].children[0]).innerText).toBe( + 'var vars = []; for (var i=0;i { + const result = new HTMLParser(window).parse(`
    test`); + + expect(result.childNodes.length).toBe(1); + expect((result.childNodes[0]).tagName).toBe('DIV'); + expect(result.childNodes[0].childNodes[0].nodeType).toBe(Node.TEXT_NODE); + }); + + it('Parses an SVG with "xmlns" set to SVG.', () => { + const result = new HTMLParser(window).parse( + ` +
    + + + + + + + + + + +
    + ` + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + ` +
    + + + + + + + + + + +
    + ` + ); + + expect(new XMLSerializer().serializeToString(result)).toBe( + ` +
    + + + + + + + + + + +
    + ` + ); + + const div = result.children[0]; + const svg = div.children[0]; + const circle = svg.children[0]; + + expect(div.namespaceURI).toBe(NamespaceURI.html); + expect(svg.namespaceURI).toBe(NamespaceURI.svg); + expect(circle.namespaceURI).toBe(NamespaceURI.svg); + + // Attributes should be in lower-case now as the namespace is HTML + expect(svg.attributes.length).toBe(4); + + expect(svg.attributes[0].name).toBe('viewBox'); + expect(svg.attributes[0].value).toBe('0 0 300 100'); + expect(svg.attributes[0].namespaceURI).toBe(null); + expect(svg.attributes[0].specified).toBe(true); + expect(svg.attributes[0].ownerElement === svg).toBe(true); + expect(svg.attributes[0].ownerDocument === document).toBe(true); + + expect(svg.attributes[1].name).toBe('stroke'); + expect(svg.attributes[1].value).toBe('red'); + expect(svg.attributes[1].namespaceURI).toBe(null); + expect(svg.attributes[1].specified).toBe(true); + expect(svg.attributes[1].ownerElement === svg).toBe(true); + expect(svg.attributes[1].ownerDocument === document).toBe(true); + + expect(svg.attributes[2].name).toBe('fill'); + expect(svg.attributes[2].value).toBe('grey'); + expect(svg.attributes[2].namespaceURI).toBe(null); + expect(svg.attributes[2].specified).toBe(true); + expect(svg.attributes[2].ownerElement === svg).toBe(true); + expect(svg.attributes[2].ownerDocument === document).toBe(true); + + expect(svg.attributes[3].name).toBe('xmlns'); + expect(svg.attributes[3].value).toBe(NamespaceURI.svg); + expect(svg.attributes[3].namespaceURI).toBe(NamespaceURI.xmlns); + expect(svg.attributes[3].specified).toBe(true); + expect(svg.attributes[3].ownerElement === svg).toBe(true); + expect(svg.attributes[3].ownerDocument === document).toBe(true); + + expect(svg.attributes['viewBox'].name).toBe('viewBox'); + expect(svg.attributes['viewBox'].value).toBe('0 0 300 100'); + expect(svg.attributes['viewBox'].namespaceURI).toBe(null); + expect(svg.attributes['viewBox'].specified).toBe(true); + expect(svg.attributes['viewBox'].ownerElement === svg).toBe(true); + expect(svg.attributes['viewBox'].ownerDocument === document).toBe(true); + + expect(svg.attributes['stroke'].name).toBe('stroke'); + expect(svg.attributes['stroke'].value).toBe('red'); + expect(svg.attributes['stroke'].namespaceURI).toBe(null); + expect(svg.attributes['stroke'].specified).toBe(true); + expect(svg.attributes['stroke'].ownerElement === svg).toBe(true); + expect(svg.attributes['stroke'].ownerDocument === document).toBe(true); + + expect(svg.attributes['fill'].name).toBe('fill'); + expect(svg.attributes['fill'].value).toBe('grey'); + expect(svg.attributes['fill'].namespaceURI).toBe(null); + expect(svg.attributes['fill'].specified).toBe(true); + expect(svg.attributes['fill'].ownerElement === svg).toBe(true); + expect(svg.attributes['fill'].ownerDocument === document).toBe(true); + + expect(svg.attributes['xmlns'].name).toBe('xmlns'); + expect(svg.attributes['xmlns'].value).toBe(NamespaceURI.svg); + expect(svg.attributes['xmlns'].namespaceURI).toBe(NamespaceURI.xmlns); + expect(svg.attributes['xmlns'].specified).toBe(true); + expect(svg.attributes['xmlns'].ownerElement === svg).toBe(true); + expect(svg.attributes['xmlns'].ownerDocument === document).toBe(true); + }); + + it('Parses an SVG with "xmlns" set to HTML.', () => { + const result = new HTMLParser(window).parse( + ` +
    + + + + + + + + + + +
    + ` + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + ` +
    + + + + + + + + + + +
    + ` + ); + + const div = result.children[0]; + const svg = div.children[0]; + const circle = svg.children[0]; + + expect(div.namespaceURI).toBe(NamespaceURI.html); + expect(svg.namespaceURI).toBe(NamespaceURI.svg); + expect(circle.namespaceURI).toBe(NamespaceURI.svg); + + // Attributes should be in lower-case now as the namespace is HTML + expect(svg.attributes.length).toBe(4); + + expect(svg.attributes[0].name).toBe('viewBox'); + expect(svg.attributes[0].value).toBe('0 0 300 100'); + expect(svg.attributes[0].namespaceURI).toBe(null); + expect(svg.attributes[0].specified).toBe(true); + expect(svg.attributes[0].ownerElement === svg).toBe(true); + expect(svg.attributes[0].ownerDocument === document).toBe(true); + + expect(svg.attributes[1].name).toBe('stroke'); + expect(svg.attributes[1].value).toBe('red'); + expect(svg.attributes[1].namespaceURI).toBe(null); + expect(svg.attributes[1].specified).toBe(true); + expect(svg.attributes[1].ownerElement === svg).toBe(true); + expect(svg.attributes[1].ownerDocument === document).toBe(true); + + expect(svg.attributes[2].name).toBe('fill'); + expect(svg.attributes[2].value).toBe('grey'); + expect(svg.attributes[2].namespaceURI).toBe(null); + expect(svg.attributes[2].specified).toBe(true); + expect(svg.attributes[2].ownerElement === svg).toBe(true); + expect(svg.attributes[2].ownerDocument === document).toBe(true); + + expect(svg.attributes[3].name).toBe('xmlns'); + expect(svg.attributes[3].value).toBe(NamespaceURI.html); + expect(svg.attributes[3].namespaceURI).toBe(NamespaceURI.xmlns); + expect(svg.attributes[3].specified).toBe(true); + expect(svg.attributes[3].ownerElement === svg).toBe(true); + expect(svg.attributes[3].ownerDocument === document).toBe(true); + + expect(svg.attributes['viewBox'].name).toBe('viewBox'); + expect(svg.attributes['viewBox'].value).toBe('0 0 300 100'); + expect(svg.attributes['viewBox'].namespaceURI).toBe(null); + expect(svg.attributes['viewBox'].specified).toBe(true); + expect(svg.attributes['viewBox'].ownerElement === svg).toBe(true); + expect(svg.attributes['viewBox'].ownerDocument === document).toBe(true); + + expect(svg.attributes['stroke'].name).toBe('stroke'); + expect(svg.attributes['stroke'].value).toBe('red'); + expect(svg.attributes['stroke'].namespaceURI).toBe(null); + expect(svg.attributes['stroke'].specified).toBe(true); + expect(svg.attributes['stroke'].ownerElement === svg).toBe(true); + expect(svg.attributes['stroke'].ownerDocument === document).toBe(true); + + expect(svg.attributes['fill'].name).toBe('fill'); + expect(svg.attributes['fill'].value).toBe('grey'); + expect(svg.attributes['fill'].namespaceURI).toBe(null); + expect(svg.attributes['fill'].specified).toBe(true); + expect(svg.attributes['fill'].ownerElement === svg).toBe(true); + expect(svg.attributes['fill'].ownerDocument === document).toBe(true); + + expect(svg.attributes['xmlns'].name).toBe('xmlns'); + expect(svg.attributes['xmlns'].value).toBe(NamespaceURI.html); + expect(svg.attributes['xmlns'].namespaceURI).toBe(NamespaceURI.xmlns); + expect(svg.attributes['xmlns'].specified).toBe(true); + expect(svg.attributes['xmlns'].ownerElement === svg).toBe(true); + expect(svg.attributes['xmlns'].ownerDocument === document).toBe(true); + }); + + it('Parses a malformed SVG with "xmlns" set to HTML.', () => { + const result = new HTMLParser(window).parse( + ` +
    + + + + + + + + + + + + + + + + +
    + ` + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + ` +
    + + + + + + + + + + + + + + + + +
    + ` + ); + + expect(new XMLSerializer().serializeToString(result)).toBe( + ` +
    + + + + + + + + + + + + + + + + +
    + ` + ); + }); + + it('Parses childless elements with start and end tag names in different case', () => { + const result = new HTMLParser(window).parse( + ` + + ` + ); + + expect((result.children[0]).innerText).toBe(`console.log('hello')`); + }); + + it('Handles different value types.', () => { + const root1 = new HTMLParser(window).parse((null)); + expect(new HTMLSerializer().serializeToString(root1)).toBe('null'); + + const root2 = new HTMLParser(window).parse((undefined)); + expect(new HTMLSerializer().serializeToString(root2)).toBe('undefined'); + + const root3 = new HTMLParser(window).parse((1000)); + expect(new HTMLSerializer().serializeToString(root3)).toBe('1000'); + + const root4 = new HTMLParser(window).parse((false)); + expect(new HTMLSerializer().serializeToString(root4)).toBe('false'); + }); + + it('Parses conditional comments', () => { + const testHTML = [ + // Conditional comment - IE 8 + '', + + // Conditional comment - IE 7 + '', + + // Conditional comment - IE 5 + '', + + // Conditional comment - IE 5.0000 + '', + + // Conditional comment - WindowsEdition 1 + '', + + // Conditional comment - Contoso 2 + '' + ]; + + for (const html of testHTML) { + const result = new HTMLParser(window).parse(html); + expect(new HTMLSerializer().serializeToString(result)).toBe(html); + } + }); + + it('Parses conditional comments for IE 9 with multiple scripts', () => { + const html = ` + + + Title + + + + + + + `; + + const expected = ` + + Title + + + + + + + `; + + const result = new HTMLParser(window).parse( + html, + document.implementation.createHTMLDocument() + ); + expect(new HTMLSerializer().serializeToString(result)).toBe(expected); + }); + + it('Parses comments with dash in them.', () => { + const result = new HTMLParser(window).parse(''); + expect(result.childNodes.length).toBe(1); + expect(result.childNodes[0].nodeType).toBe(NodeTypeEnum.commentNode); + expect(result.childNodes[0].nodeValue).toBe(' comment with - in - it '); + }); + + it('Parses