From 901a709fb8baea2d7adf1eb22a0c8f202af44cc3 Mon Sep 17 00:00:00 2001 From: Matthew Lieder Date: Sat, 15 Oct 2022 13:38:12 -0500 Subject: [PATCH 01/16] #622@patch: Fix Element.matches failing when using non-matching descendant selector on element attached to document. --- packages/happy-dom/src/query-selector/QuerySelector.ts | 8 ++++---- packages/happy-dom/test/nodes/element/Element.test.ts | 2 ++ packages/happy-dom/test/nodes/node/Node.test.ts | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index ad0958c60..b0c702022 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -104,15 +104,15 @@ export default class QuerySelector { } const selector = new SelectorItem(selectorParts[0]); - const result = selector.match(currentNode); + const result = selector.match(currentNode); - if ((targetNode === currentNode || !currentNode.parentNode) && !result.matches) { + if ((targetNode === currentNode || !currentNode.parentElement) && !result.matches) { return { priorityWeight: 0, matches: false }; } return this.matchesSelector( - isDirectChild ? currentNode.parentNode : targetNode, - currentNode.parentNode, + isDirectChild ? currentNode.parentElement : targetNode, + currentNode.parentElement, result.matches ? selectorParts.slice(1) : selectorParts, priorityWeight + result.priorityWeight ); diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 3d0489872..0d6b551a7 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -698,6 +698,7 @@ describe('Element', () => { it('Checks if the element matches with a descendant combinator', () => { const grandparentElement = document.createElement('div'); grandparentElement.setAttribute('role', 'alert'); + document.appendChild(grandparentElement); const parentElement = document.createElement('div'); parentElement.setAttribute('role', 'status'); @@ -709,6 +710,7 @@ describe('Element', () => { expect(element.matches('div[role="alert"] div.active')).toBe(true); expect(element.matches('div[role="article"] div.active')).toBe(false); + expect(element.matches('.nonexistent-class div.active')).toBe(false); }); it('Checks if the element matches with a child combinator', () => { diff --git a/packages/happy-dom/test/nodes/node/Node.test.ts b/packages/happy-dom/test/nodes/node/Node.test.ts index 53ed11f1e..57c7a5173 100644 --- a/packages/happy-dom/test/nodes/node/Node.test.ts +++ b/packages/happy-dom/test/nodes/node/Node.test.ts @@ -196,6 +196,14 @@ describe('Node', () => { expect(text.parentElement).toBe(null); }); + + it('Returns null if parent node is not an element.', () => { + const htmlElement = document.createElement('html'); + document.appendChild(htmlElement); + + expect(htmlElement.parentNode).toBe(document); + expect(htmlElement.parentElement).toBe(null); + }); }); describe('get baseURI()', () => { From 4906155c902fc941738e41cd4dad8fa61f411afd Mon Sep 17 00:00:00 2001 From: Matthew Lieder Date: Mon, 17 Oct 2022 21:01:13 -0500 Subject: [PATCH 02/16] #622@patch: Fix Element.matches failing when using non-matching descendant selector on element detached from document. --- .../happy-dom/src/query-selector/QuerySelector.ts | 9 ++++++++- .../happy-dom/test/nodes/element/Element.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index b0c702022..7bf78ad8c 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -106,7 +106,14 @@ export default class QuerySelector { const selector = new SelectorItem(selectorParts[0]); const result = selector.match(currentNode); - if ((targetNode === currentNode || !currentNode.parentElement) && !result.matches) { + if (result.matches && selectorParts.length === 1) { + return { + priorityWeight: priorityWeight + result.priorityWeight, + matches: true + }; + } + + if (!currentNode.parentElement || (targetNode === currentNode && !result.matches)) { return { priorityWeight: 0, matches: false }; } diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 0d6b551a7..f35e87e1d 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -713,6 +713,19 @@ describe('Element', () => { expect(element.matches('.nonexistent-class div.active')).toBe(false); }); + it('Checks if a detached element matches with a descendant combinator', () => { + const parentElement = document.createElement('div'); + parentElement.setAttribute('role', 'status'); + + const element = document.createElement('div'); + element.className = 'active'; + parentElement.appendChild(element); + + expect(element.matches('div[role="status"] div.active')).toBe(true); + expect(element.matches('div[role="article"] div.active')).toBe(false); + expect(parentElement.matches('.nonexistent-class div[role="status"]')).toBe(false); + }); + it('Checks if the element matches with a child combinator', () => { const grandparentElement = document.createElement('div'); grandparentElement.setAttribute('role', 'alert'); @@ -727,6 +740,7 @@ describe('Element', () => { expect(element.matches('div[role="status"] > div.active')).toBe(true); expect(element.matches('div[role="alert"] > div.active')).toBe(false); + expect(grandparentElement.matches('div > div[role="alert"]')).toBe(false); }); }); From 167cabf5d808c3f18e1387effd0415d14fa0e6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Ledentu?= Date: Tue, 18 Oct 2022 14:46:57 +0200 Subject: [PATCH 03/16] #308@minor: Implement NamedNodeMap. --- .../happy-dom/src/nodes/attr/INamedNodeMap.ts | 23 +++ .../happy-dom/src/nodes/attr/NamedNodeMap.ts | 133 ++++++++++++++++++ .../happy-dom/src/nodes/element/Element.ts | 10 +- .../happy-dom/src/nodes/element/IElement.ts | 3 +- .../happy-dom/src/nodes/node/NodeUtility.ts | 4 +- packages/happy-dom/src/window/IWindow.ts | 2 + packages/happy-dom/src/window/Window.ts | 2 + .../test/nodes/element/Element.test.ts | 10 +- 8 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 packages/happy-dom/src/nodes/attr/INamedNodeMap.ts create mode 100644 packages/happy-dom/src/nodes/attr/NamedNodeMap.ts diff --git a/packages/happy-dom/src/nodes/attr/INamedNodeMap.ts b/packages/happy-dom/src/nodes/attr/INamedNodeMap.ts new file mode 100644 index 000000000..cab5c9516 --- /dev/null +++ b/packages/happy-dom/src/nodes/attr/INamedNodeMap.ts @@ -0,0 +1,23 @@ +import IAttr from './IAttr'; + +export type INamedNodeMapProps = { + readonly length: number; + item: (index: number) => IAttr; + getNamedItem: (qualifiedName: string) => IAttr; + getNamedItemNS: (namespace: string, localName: string) => IAttr; + setNamedItem: (attr: IAttr) => IAttr; + setNamedItemNS: (attr: IAttr) => IAttr; + removeNamedItem: (qualifiedName: string) => IAttr; + removeNamedItemNS: (namespace: string, localName: string) => IAttr; + [Symbol.toStringTag]: string; +} & Iterable; +type INamedNodeMapProperties = keyof INamedNodeMapProps; + +type INamedNodeMap = INamedNodeMapProps & { + [k in Exclude]: IAttr; +}; + +/** + * NamedNodeMap interface. + */ +export default INamedNodeMap; diff --git a/packages/happy-dom/src/nodes/attr/NamedNodeMap.ts b/packages/happy-dom/src/nodes/attr/NamedNodeMap.ts new file mode 100644 index 000000000..65ce4344a --- /dev/null +++ b/packages/happy-dom/src/nodes/attr/NamedNodeMap.ts @@ -0,0 +1,133 @@ +import type Element from '../element/Element'; +import IAttr from './IAttr'; +import type { INamedNodeMapProps } from './INamedNodeMap'; + +/** + * NamedNodeMap. + * + * Reference: https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap. + */ +export default class NamedNodeMap implements INamedNodeMapProps { + private _element: Element; + + /** + * Constructor. + * + * @param element Associated element. + */ + constructor(element: Element) { + Object.defineProperty(this, '_element', { enumerable: false, writable: true, value: element }); + } + + /** + * Returns `Symbol.toStringTag`. + * + * @returns `Symbol.toStringTag`. + */ + public get [Symbol.toStringTag](): string { + return this.constructor.name; + } + + /** + * Length. + */ + public get length(): number { + return Object.keys(this._element._attributes).length; + } + + /** + * Returns attribute by index. + * + * @param index Index. + */ + public item(index: number): IAttr | null { + if (index < 0) { + return null; + } + const attr = Object.values(this._element._attributes)[index]; + return attr ? attr : null; + } + + /** + * Returns attribute by name. + * + * @param qualifiedName Name. + */ + public getNamedItem(qualifiedName: string): IAttr | null { + return this._element.getAttributeNode(qualifiedName); + } + + /** + * Returns attribute by name and namespace. + * + * @param namespace Namespace. + * @param localName Local name of the attribute. + */ + public getNamedItemNS(namespace: string, localName: string): IAttr | null { + return this._element.getAttributeNodeNS(namespace, localName); + } + + /** + * Adds a new attribute node. + * + * @param attr Attribute. + * @returns Replaced attribute. + */ + public setNamedItem(attr: IAttr): IAttr { + return this._element.setAttributeNode(attr); + } + + /** + * Adds a new namespaced attribute node. + * + * @param attr Attribute. + * @returns Replaced attribute. + */ + public setNamedItemNS(attr: IAttr): IAttr { + return this._element.setAttributeNodeNS(attr); + } + + /** + * Removes an attribute. + * + * @param qualifiedName Name of the attribute. + * @returns Removed attribute. + */ + public removeNamedItem(qualifiedName: string): IAttr | null { + const attr = this.getNamedItem(qualifiedName); + + if (attr) { + this._element.removeAttributeNode(attr); + } + return attr; + } + + /** + * Removes a namespaced attribute. + * + * @param namespace Namespace. + * @param localName Local name of the attribute. + * @returns Removed attribute. + */ + public removeNamedItemNS(namespace: string, localName: string): IAttr | null { + const attr = this.getNamedItemNS(namespace, localName); + + if (attr) { + this._element.removeAttributeNode(attr); + } + return attr; + } + + /** + * Iterator. + */ + public [Symbol.iterator](): Iterator { + let index = -1; + return { + next: () => { + index++; + return { value: this.item(index), done: index >= this.length }; + } + }; + } +} diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index e41f4c9f6..cfef4f03b 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -1,6 +1,7 @@ import Node from '../node/Node'; import ShadowRoot from '../shadow-root/ShadowRoot'; import Attr from '../attr/Attr'; +import NamedNodeMap from '../attr/NamedNodeMap'; import DOMRect from './DOMRect'; import DOMTokenList from '../../dom-token-list/DOMTokenList'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; @@ -26,6 +27,8 @@ import IText from '../text/IText'; import IDOMRectList from './IDOMRectList'; import DOMRectListFactory from './DOMRectListFactory'; import IAttr from '../attr/IAttr'; +import INamedNodeMap from '../attr/INamedNodeMap'; + import Event from '../../event/Event'; /** @@ -249,11 +252,8 @@ export default class Element extends Node implements IElement { * * @returns Attributes. */ - public get attributes(): { [k: string | number]: IAttr } & { length: number } { - const attributes = Object.values(this._attributes); - return Object.assign({}, this._attributes, attributes, { - length: attributes.length - }); + public get attributes(): INamedNodeMap { + return Object.assign(new NamedNodeMap(this), Object.values(this._attributes), this._attributes); } /** diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index 20c6c7656..386ef2ee5 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -1,5 +1,6 @@ import IShadowRoot from '../shadow-root/IShadowRoot'; import IAttr from '../attr/IAttr'; +import INamedNodeMap from '../attr/INamedNodeMap'; import DOMRect from './DOMRect'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; import INode from './../node/INode'; @@ -29,7 +30,7 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, slot: string; readonly nodeName: string; readonly localName: string; - readonly attributes: { [k: string | number]: IAttr } & { length: number }; + readonly attributes: INamedNodeMap; // Events oncancel: (event: Event) => void | null; diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index 12e91da93..bd331d8e3 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -148,8 +148,8 @@ export default class NodeUtility { * @param elementB */ public static attributeListsEqual(elementA: IElement, elementB: IElement): boolean { - const listA = Object.values(elementA.attributes); - const listB = Object.values(elementB.attributes); + const listA = Array.from(elementA.attributes); + const listB = Array.from(elementB.attributes); const lengthA = listA.length; const lengthB = listB.length; diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 3d9a61eb9..4b6aed156 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -95,6 +95,7 @@ import MediaQueryList from '../match-media/MediaQueryList'; import DOMRect from '../nodes/element/DOMRect'; import Window from './Window'; import Attr from '../nodes/attr/Attr'; +import NamedNodeMap from '../nodes/attr/NamedNodeMap'; import { URLSearchParams } from 'url'; import { Performance } from 'perf_hooks'; import IElement from '../nodes/element/IElement'; @@ -134,6 +135,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly HTMLBaseElement: typeof HTMLBaseElement; readonly HTMLDialogElement: typeof HTMLDialogElement; readonly Attr: typeof Attr; + readonly NamedNodeMap: typeof NamedNodeMap; readonly SVGSVGElement: typeof SVGSVGElement; readonly SVGElement: typeof SVGElement; readonly Image: typeof Image; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 5120800a6..d39353627 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -108,6 +108,7 @@ import { Buffer } from 'buffer'; import Base64 from '../base64/Base64'; import IDocument from '../nodes/document/IDocument'; import Attr from '../nodes/attr/Attr'; +import NamedNodeMap from '../nodes/attr/NamedNodeMap'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; @@ -167,6 +168,7 @@ export default class Window extends EventTarget implements IWindow { public readonly HTMLBaseElement = HTMLBaseElement; public readonly HTMLDialogElement = HTMLDialogElement; public readonly Attr = Attr; + public readonly NamedNodeMap = NamedNodeMap; public readonly SVGSVGElement = SVGSVGElement; public readonly SVGElement = SVGElement; public readonly Text = Text; diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 0d6b551a7..203445b66 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -1198,9 +1198,7 @@ describe('Element', () => { it('Removes an attribute.', () => { element.setAttribute('key1', 'value1'); element.removeAttribute('key1'); - expect(element.attributes).toEqual({ - length: 0 - }); + expect(element.attributes.length).toBe(0); }); }); @@ -1208,9 +1206,7 @@ describe('Element', () => { it('Removes a namespace attribute.', () => { element.setAttributeNS(NAMESPACE_URI, 'global:local', 'value'); element.removeAttributeNS(NAMESPACE_URI, 'local'); - expect(element.attributes).toEqual({ - length: 0 - }); + expect(element.attributes.length).toBe(0); }); }); @@ -1478,7 +1474,7 @@ describe('Element', () => { element.setAttributeNode(attribute); element[method](attribute); - expect(element.attributes).toEqual({ length: 0 }); + expect(element.attributes.length).toBe(0); }); }); } From fbb7e748f5cfef219c3589db4515d779b73fbe28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Ledentu?= Date: Tue, 18 Oct 2022 16:50:00 +0200 Subject: [PATCH 04/16] #308@trivial: Refactor and add tests on NamedNodeMap. --- .../attr => named-node-map}/INamedNodeMap.ts | 2 +- .../attr => named-node-map}/NamedNodeMap.ts | 30 +++-- .../happy-dom/src/nodes/element/Element.ts | 4 +- .../happy-dom/src/nodes/element/IElement.ts | 2 +- packages/happy-dom/src/window/IWindow.ts | 2 +- packages/happy-dom/src/window/Window.ts | 2 +- .../test/named-node-map/NamedNodeMap.test.ts | 124 ++++++++++++++++++ 7 files changed, 148 insertions(+), 18 deletions(-) rename packages/happy-dom/src/{nodes/attr => named-node-map}/INamedNodeMap.ts (94%) rename packages/happy-dom/src/{nodes/attr => named-node-map}/NamedNodeMap.ts (75%) create mode 100644 packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts diff --git a/packages/happy-dom/src/nodes/attr/INamedNodeMap.ts b/packages/happy-dom/src/named-node-map/INamedNodeMap.ts similarity index 94% rename from packages/happy-dom/src/nodes/attr/INamedNodeMap.ts rename to packages/happy-dom/src/named-node-map/INamedNodeMap.ts index cab5c9516..f05de158b 100644 --- a/packages/happy-dom/src/nodes/attr/INamedNodeMap.ts +++ b/packages/happy-dom/src/named-node-map/INamedNodeMap.ts @@ -1,4 +1,4 @@ -import IAttr from './IAttr'; +import IAttr from '../nodes/attr/IAttr'; export type INamedNodeMapProps = { readonly length: number; diff --git a/packages/happy-dom/src/nodes/attr/NamedNodeMap.ts b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts similarity index 75% rename from packages/happy-dom/src/nodes/attr/NamedNodeMap.ts rename to packages/happy-dom/src/named-node-map/NamedNodeMap.ts index 65ce4344a..c3c1317fe 100644 --- a/packages/happy-dom/src/nodes/attr/NamedNodeMap.ts +++ b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts @@ -1,5 +1,5 @@ -import type Element from '../element/Element'; -import IAttr from './IAttr'; +import type Element from '../nodes/element/Element'; +import IAttr from '../nodes/attr/IAttr'; import type { INamedNodeMapProps } from './INamedNodeMap'; /** @@ -8,7 +8,10 @@ import type { INamedNodeMapProps } from './INamedNodeMap'; * Reference: https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap. */ export default class NamedNodeMap implements INamedNodeMapProps { - private _element: Element; + /** + * Reference to the element. + */ + private _ownerElement: Element; /** * Constructor. @@ -16,7 +19,10 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @param element Associated element. */ constructor(element: Element) { - Object.defineProperty(this, '_element', { enumerable: false, writable: true, value: element }); + Object.defineProperty(this, '_ownerElement', { + enumerable: false, + value: element + }); } /** @@ -32,7 +38,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * Length. */ public get length(): number { - return Object.keys(this._element._attributes).length; + return Object.keys(this._ownerElement._attributes).length; } /** @@ -44,7 +50,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { if (index < 0) { return null; } - const attr = Object.values(this._element._attributes)[index]; + const attr = Object.values(this._ownerElement._attributes)[index]; return attr ? attr : null; } @@ -54,7 +60,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @param qualifiedName Name. */ public getNamedItem(qualifiedName: string): IAttr | null { - return this._element.getAttributeNode(qualifiedName); + return this._ownerElement.getAttributeNode(qualifiedName); } /** @@ -64,7 +70,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @param localName Local name of the attribute. */ public getNamedItemNS(namespace: string, localName: string): IAttr | null { - return this._element.getAttributeNodeNS(namespace, localName); + return this._ownerElement.getAttributeNodeNS(namespace, localName); } /** @@ -74,7 +80,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @returns Replaced attribute. */ public setNamedItem(attr: IAttr): IAttr { - return this._element.setAttributeNode(attr); + return this._ownerElement.setAttributeNode(attr); } /** @@ -84,7 +90,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @returns Replaced attribute. */ public setNamedItemNS(attr: IAttr): IAttr { - return this._element.setAttributeNodeNS(attr); + return this._ownerElement.setAttributeNodeNS(attr); } /** @@ -97,7 +103,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { const attr = this.getNamedItem(qualifiedName); if (attr) { - this._element.removeAttributeNode(attr); + this._ownerElement.removeAttributeNode(attr); } return attr; } @@ -113,7 +119,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { const attr = this.getNamedItemNS(namespace, localName); if (attr) { - this._element.removeAttributeNode(attr); + this._ownerElement.removeAttributeNode(attr); } return attr; } diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index cfef4f03b..0e90932d0 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -1,7 +1,7 @@ import Node from '../node/Node'; import ShadowRoot from '../shadow-root/ShadowRoot'; import Attr from '../attr/Attr'; -import NamedNodeMap from '../attr/NamedNodeMap'; +import NamedNodeMap from '../../named-node-map/NamedNodeMap'; import DOMRect from './DOMRect'; import DOMTokenList from '../../dom-token-list/DOMTokenList'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; @@ -27,7 +27,7 @@ import IText from '../text/IText'; import IDOMRectList from './IDOMRectList'; import DOMRectListFactory from './DOMRectListFactory'; import IAttr from '../attr/IAttr'; -import INamedNodeMap from '../attr/INamedNodeMap'; +import INamedNodeMap from '../../named-node-map/INamedNodeMap'; import Event from '../../event/Event'; diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index 386ef2ee5..7649cd0bf 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -1,6 +1,6 @@ import IShadowRoot from '../shadow-root/IShadowRoot'; import IAttr from '../attr/IAttr'; -import INamedNodeMap from '../attr/INamedNodeMap'; +import INamedNodeMap from '../../named-node-map/INamedNodeMap'; import DOMRect from './DOMRect'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; import INode from './../node/INode'; diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 4b6aed156..94dee772f 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -95,7 +95,7 @@ import MediaQueryList from '../match-media/MediaQueryList'; import DOMRect from '../nodes/element/DOMRect'; import Window from './Window'; import Attr from '../nodes/attr/Attr'; -import NamedNodeMap from '../nodes/attr/NamedNodeMap'; +import NamedNodeMap from '../named-node-map/NamedNodeMap'; import { URLSearchParams } from 'url'; import { Performance } from 'perf_hooks'; import IElement from '../nodes/element/IElement'; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index d39353627..ac23a14f1 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -108,7 +108,7 @@ import { Buffer } from 'buffer'; import Base64 from '../base64/Base64'; import IDocument from '../nodes/document/IDocument'; import Attr from '../nodes/attr/Attr'; -import NamedNodeMap from '../nodes/attr/NamedNodeMap'; +import NamedNodeMap from '../named-node-map/NamedNodeMap'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; diff --git a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts new file mode 100644 index 000000000..f2812903b --- /dev/null +++ b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts @@ -0,0 +1,124 @@ +import IWindow from '../../src/window/IWindow'; +import Window from '../../src/window/Window'; +import IDocument from '../../src/nodes/document/IDocument'; +import IElement from '../../src/nodes/element/IElement'; +import INamedNodeMap from 'src/named-node-map/INamedNodeMap'; + +describe('NamedNodeMap', () => { + let window: IWindow; + let document: IDocument; + let element: IElement; + let attributes: INamedNodeMap; + + beforeEach(() => { + window = new Window(); + document = window.document; + element = document.createElement('div'); + attributes = element.attributes; + }); + + describe('get length()', () => { + it('Is an integer representing the number of objects stored in the object.', () => { + element.setAttribute('key1', 'value1'); + element.setAttribute('key2', 'value2'); + + expect(attributes.length).toBe(2); + + element.setAttribute('key3', 'value3'); + + expect(attributes.length).toBe(3); + }); + }); + + describe('item()', () => { + it('Returns an attribute by index.', () => { + element.setAttribute('key1', 'value1'); + element.setAttribute('key2', 'value2'); + + expect(attributes.item(0).name).toBe('key1'); + expect(attributes.item(0).value).toBe('value1'); + expect(attributes.item(1).name).toBe('key2'); + expect(attributes.item(1).value).toBe('value2'); + }); + }); + + describe('getNamedItem()', () => { + it('Returns an attribute by name.', () => { + element.setAttribute('key1', 'value1'); + element.setAttribute('key2', 'value2'); + + expect(attributes.getNamedItem('key1').name).toBe('key1'); + expect(attributes.getNamedItem('key1').value).toBe('value1'); + expect(attributes.getNamedItem('key2').name).toBe('key2'); + expect(attributes.getNamedItem('key2').value).toBe('value2'); + }); + }); + + describe('getNamedItemNS()', () => { + it('Returns an attribute by name.', () => { + element.setAttributeNS('namespace', 'key1', 'value1'); + element.setAttributeNS('namespace', 'key2', 'value2'); + + expect(attributes.getNamedItemNS('namespace', 'key1').name).toBe('key1'); + expect(attributes.getNamedItemNS('namespace', 'key1').value).toBe('value1'); + expect(attributes.getNamedItemNS('namespace', 'key2').name).toBe('key2'); + expect(attributes.getNamedItemNS('namespace', 'key2').value).toBe('value2'); + }); + }); + + describe('setNamedItem()', () => { + it('Adds an attribute when not existing.', () => { + element.setAttribute('key', 'value'); + const attr = attributes.removeNamedItem('key'); + + expect(attributes.getNamedItem('key')).toBe(null); + + attributes.setNamedItem(attr); + + expect(attributes.getNamedItem('key')).toBe(attr); + }); + + it('Replaces an attribute when existing.', () => { + element.setAttribute('key', 'value1'); + const attr = attributes.getNamedItem('key'); + attr.value = 'value2'; + + attributes.setNamedItem(attr); + + expect(attributes.getNamedItem('key')).toBe(attr); + expect(element.getAttribute('key')).toBe('value2'); + }); + }); + + describe('setNamedItemNS()', () => { + it('Adds an namespaced attribute when not existing.', () => { + element.setAttributeNS('namespace', 'key', 'value'); + const attr = attributes.removeNamedItemNS('namespace', 'key'); + + attributes.setNamedItemNS(attr); + + expect(attributes.getNamedItem('key')).toBe(attr); + expect(element.getAttributeNS('namespace', 'key')).toBe('value'); + }); + + it('Replaces an attribute when existing.', () => { + element.setAttributeNS('namespace', 'key', 'value1'); + const attr = attributes.getNamedItemNS('namespace', 'key'); + attr.value = 'value2'; + + attributes.setNamedItemNS(attr); + + expect(attributes.getNamedItemNS('namespace', 'key')).toBe(attr); + expect(element.getAttributeNS('namespace', 'key')).toBe('value2'); + }); + }); + + describe('removeNamedItem()', () => { + it('Removes an attribute from the list.', () => { + element.setAttribute('key', 'value'); + attributes.removeNamedItem('key'); + + expect(element.getAttribute('key')).toBe(null); + }); + }); +}); From dad18e4a76c35d549f6a8affa3b4572d337c2f06 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 18 Oct 2022 23:25:59 +0200 Subject: [PATCH 05/16] #308@trivial: Improves interface for NamedNodeMap and adds some more unit tests. --- .../src/named-node-map/INamedNodeMap.ts | 73 +++++++++-- .../src/named-node-map/NamedNodeMap.ts | 42 ++++--- .../happy-dom/src/nodes/node/NodeUtility.ts | 4 +- .../test/named-node-map/NamedNodeMap.test.ts | 40 +++++- .../test/nodes/element/Element.test.ts | 116 +++++++++--------- .../test/xml-parser/XMLParser.test.ts | 82 ++++++------- 6 files changed, 220 insertions(+), 137 deletions(-) diff --git a/packages/happy-dom/src/named-node-map/INamedNodeMap.ts b/packages/happy-dom/src/named-node-map/INamedNodeMap.ts index f05de158b..a0a904fc8 100644 --- a/packages/happy-dom/src/named-node-map/INamedNodeMap.ts +++ b/packages/happy-dom/src/named-node-map/INamedNodeMap.ts @@ -1,23 +1,70 @@ import IAttr from '../nodes/attr/IAttr'; -export type INamedNodeMapProps = { +/** + * NamedNodeMap. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap. + */ +export default interface INamedNodeMap extends Iterable { + [index: number]: IAttr; + [Symbol.toStringTag]: string; readonly length: number; + + /** + * Returns attribute by index. + * + * @param index Index. + */ item: (index: number) => IAttr; + + /** + * Returns attribute by name. + * + * @param qualifiedName Name. + * @returns Attribute. + */ getNamedItem: (qualifiedName: string) => IAttr; + + /** + * Returns attribute by name and namespace. + * + * @param namespace Namespace. + * @param localName Local name of the attribute. + * @returns Attribute. + */ getNamedItemNS: (namespace: string, localName: string) => IAttr; + + /** + * Adds a new attribute node. + * + * @param attr Attribute. + * @returns Replaced attribute. + */ setNamedItem: (attr: IAttr) => IAttr; + + /** + * Adds a new namespaced attribute node. + * + * @param attr Attribute. + * @returns Replaced attribute. + */ setNamedItemNS: (attr: IAttr) => IAttr; - removeNamedItem: (qualifiedName: string) => IAttr; - removeNamedItemNS: (namespace: string, localName: string) => IAttr; - [Symbol.toStringTag]: string; -} & Iterable; -type INamedNodeMapProperties = keyof INamedNodeMapProps; -type INamedNodeMap = INamedNodeMapProps & { - [k in Exclude]: IAttr; -}; + /** + * Removes an attribute. + * + * @param qualifiedName Name of the attribute. + * @returns Removed attribute. + */ + removeNamedItem: (qualifiedName: string) => IAttr; -/** - * NamedNodeMap interface. - */ -export default INamedNodeMap; + /** + * Removes a namespaced attribute. + * + * @param namespace Namespace. + * @param localName Local name of the attribute. + * @returns Removed attribute. + */ + removeNamedItemNS: (namespace: string, localName: string) => IAttr; +} diff --git a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts index c3c1317fe..19b18968a 100644 --- a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts +++ b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts @@ -1,17 +1,20 @@ import type Element from '../nodes/element/Element'; import IAttr from '../nodes/attr/IAttr'; -import type { INamedNodeMapProps } from './INamedNodeMap'; +import INamedNodeMap from './INamedNodeMap'; /** * NamedNodeMap. * - * Reference: https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap. + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap. */ -export default class NamedNodeMap implements INamedNodeMapProps { +export default class NamedNodeMap implements INamedNodeMap { + [index: number]: IAttr; + /** * Reference to the element. */ - private _ownerElement: Element; + #ownerElement: Element; /** * Constructor. @@ -19,16 +22,13 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @param element Associated element. */ constructor(element: Element) { - Object.defineProperty(this, '_ownerElement', { - enumerable: false, - value: element - }); + this.#ownerElement = element; } /** - * Returns `Symbol.toStringTag`. + * Returns string. * - * @returns `Symbol.toStringTag`. + * @returns string. */ public get [Symbol.toStringTag](): string { return this.constructor.name; @@ -36,9 +36,11 @@ export default class NamedNodeMap implements INamedNodeMapProps { /** * Length. + * + * @returns Length. */ public get length(): number { - return Object.keys(this._ownerElement._attributes).length; + return Object.keys(this.#ownerElement._attributes).length; } /** @@ -50,7 +52,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { if (index < 0) { return null; } - const attr = Object.values(this._ownerElement._attributes)[index]; + const attr = Object.values(this.#ownerElement._attributes)[index]; return attr ? attr : null; } @@ -58,9 +60,10 @@ export default class NamedNodeMap implements INamedNodeMapProps { * Returns attribute by name. * * @param qualifiedName Name. + * @returns Attribute. */ public getNamedItem(qualifiedName: string): IAttr | null { - return this._ownerElement.getAttributeNode(qualifiedName); + return this.#ownerElement.getAttributeNode(qualifiedName); } /** @@ -68,9 +71,10 @@ export default class NamedNodeMap implements INamedNodeMapProps { * * @param namespace Namespace. * @param localName Local name of the attribute. + * @returns Attribute. */ public getNamedItemNS(namespace: string, localName: string): IAttr | null { - return this._ownerElement.getAttributeNodeNS(namespace, localName); + return this.#ownerElement.getAttributeNodeNS(namespace, localName); } /** @@ -80,7 +84,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @returns Replaced attribute. */ public setNamedItem(attr: IAttr): IAttr { - return this._ownerElement.setAttributeNode(attr); + return this.#ownerElement.setAttributeNode(attr); } /** @@ -90,7 +94,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { * @returns Replaced attribute. */ public setNamedItemNS(attr: IAttr): IAttr { - return this._ownerElement.setAttributeNodeNS(attr); + return this.#ownerElement.setAttributeNodeNS(attr); } /** @@ -103,7 +107,7 @@ export default class NamedNodeMap implements INamedNodeMapProps { const attr = this.getNamedItem(qualifiedName); if (attr) { - this._ownerElement.removeAttributeNode(attr); + this.#ownerElement.removeAttributeNode(attr); } return attr; } @@ -119,13 +123,15 @@ export default class NamedNodeMap implements INamedNodeMapProps { const attr = this.getNamedItemNS(namespace, localName); if (attr) { - this._ownerElement.removeAttributeNode(attr); + this.#ownerElement.removeAttributeNode(attr); } return attr; } /** * Iterator. + * + * @returns Iterator. */ public [Symbol.iterator](): Iterator { let index = -1; diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index bd331d8e3..0cafdcf0a 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -148,8 +148,8 @@ export default class NodeUtility { * @param elementB */ public static attributeListsEqual(elementA: IElement, elementB: IElement): boolean { - const listA = Array.from(elementA.attributes); - const listB = Array.from(elementB.attributes); + const listA = >Object.values(elementA['_attributes']); + const listB = >Object.values(elementB['_attributes']); const lengthA = listA.length; const lengthB = listB.length; diff --git a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts index f2812903b..9b1214a66 100644 --- a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts +++ b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts @@ -17,12 +17,34 @@ describe('NamedNodeMap', () => { attributes = element.attributes; }); - describe('get length()', () => { - it('Is an integer representing the number of objects stored in the object.', () => { + describe('get toString()', () => { + it('Returns a stirng.', () => { + expect(attributes.toString()).toBe('[object NamedNodeMap]'); + }); + }); + + describe('get toString()', () => { + it('Returns a stirng.', () => { + expect(attributes.toString()).toBe('[object NamedNodeMap]'); + }); + }); + + describe('Symbol.iterator()', () => { + it('Handles being an iterator.', () => { element.setAttribute('key1', 'value1'); element.setAttribute('key2', 'value2'); + const attributeList = []; + + for (const attribute of attributes) { + attributeList.push(attribute); + } + expect(attributes.length).toBe(2); + expect(attributeList[0].name).toBe('key1'); + expect(attributeList[0].value).toBe('value1'); + expect(attributeList[1].name).toBe('key2'); + expect(attributeList[1].value).toBe('value2'); element.setAttribute('key3', 'value3'); @@ -83,8 +105,10 @@ describe('NamedNodeMap', () => { const attr = attributes.getNamedItem('key'); attr.value = 'value2'; - attributes.setNamedItem(attr); + const replaced = attributes.setNamedItem(attr); + expect(replaced.name).toBe('key'); + expect(replaced.value).toBe('value1'); expect(attributes.getNamedItem('key')).toBe(attr); expect(element.getAttribute('key')).toBe('value2'); }); @@ -106,7 +130,10 @@ describe('NamedNodeMap', () => { const attr = attributes.getNamedItemNS('namespace', 'key'); attr.value = 'value2'; - attributes.setNamedItemNS(attr); + const replaced = attributes.setNamedItemNS(attr); + + expect(replaced.name).toBe('key'); + expect(replaced.value).toBe('value1'); expect(attributes.getNamedItemNS('namespace', 'key')).toBe(attr); expect(element.getAttributeNS('namespace', 'key')).toBe('value2'); @@ -116,7 +143,10 @@ describe('NamedNodeMap', () => { describe('removeNamedItem()', () => { it('Removes an attribute from the list.', () => { element.setAttribute('key', 'value'); - attributes.removeNamedItem('key'); + const removed = attributes.removeNamedItem('key'); + + expect(removed.name).toBe('key'); + expect(removed.value).toBe('value'); expect(element.getAttribute('key')).toBe(null); }); diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 203445b66..b07af70b0 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -267,26 +267,26 @@ describe('Element', () => { expect(element.attributes[2].ownerElement === element).toBe(true); expect(element.attributes[2].ownerDocument === document).toBe(true); - expect(element.attributes.key1.name).toBe('key1'); - expect(element.attributes.key1.value).toBe('value1'); - expect(element.attributes.key1.namespaceURI).toBe(null); - expect(element.attributes.key1.specified).toBe(true); - expect(element.attributes.key1.ownerElement === element).toBe(true); - expect(element.attributes.key1.ownerDocument === document).toBe(true); - - expect(element.attributes.key2.name).toBe('key2'); - expect(element.attributes.key2.value).toBe('value2'); - expect(element.attributes.key2.namespaceURI).toBe(null); - expect(element.attributes.key2.specified).toBe(true); - expect(element.attributes.key2.ownerElement === element).toBe(true); - expect(element.attributes.key2.ownerDocument === document).toBe(true); - - expect(element.attributes.key3.name).toBe('key3'); - expect(element.attributes.key3.value).toBe('value3'); - expect(element.attributes.key3.namespaceURI).toBe(null); - expect(element.attributes.key3.specified).toBe(true); - expect(element.attributes.key3.ownerElement === element).toBe(true); - expect(element.attributes.key3.ownerDocument === document).toBe(true); + expect(element.attributes['key1'].name).toBe('key1'); + expect(element.attributes['key1'].value).toBe('value1'); + expect(element.attributes['key1'].namespaceURI).toBe(null); + expect(element.attributes['key1'].specified).toBe(true); + expect(element.attributes['key1'].ownerElement === element).toBe(true); + expect(element.attributes['key1'].ownerDocument === document).toBe(true); + + expect(element.attributes['key2'].name).toBe('key2'); + expect(element.attributes['key2'].value).toBe('value2'); + expect(element.attributes['key2'].namespaceURI).toBe(null); + expect(element.attributes['key2'].specified).toBe(true); + expect(element.attributes['key2'].ownerElement === element).toBe(true); + expect(element.attributes['key2'].ownerDocument === document).toBe(true); + + expect(element.attributes['key3'].name).toBe('key3'); + expect(element.attributes['key3'].value).toBe('value3'); + expect(element.attributes['key3'].namespaceURI).toBe(null); + expect(element.attributes['key3'].specified).toBe(true); + expect(element.attributes['key3'].ownerElement === element).toBe(true); + expect(element.attributes['key3'].ownerDocument === document).toBe(true); }); }); @@ -1106,19 +1106,19 @@ describe('Element', () => { expect(element.attributes[1].ownerElement === element).toBe(true); expect(element.attributes[1].ownerDocument === document).toBe(true); - expect(element.attributes.key1.name).toBe('key1'); - expect(element.attributes.key1.value).toBe('value1'); - expect(element.attributes.key1.namespaceURI).toBe(null); - expect(element.attributes.key1.specified).toBe(true); - expect(element.attributes.key1.ownerElement === element).toBe(true); - expect(element.attributes.key1.ownerDocument === document).toBe(true); + expect(element.attributes['key1'].name).toBe('key1'); + expect(element.attributes['key1'].value).toBe('value1'); + expect(element.attributes['key1'].namespaceURI).toBe(null); + expect(element.attributes['key1'].specified).toBe(true); + expect(element.attributes['key1'].ownerElement === element).toBe(true); + expect(element.attributes['key1'].ownerDocument === document).toBe(true); - expect(element.attributes.key2.name).toBe('key2'); - expect(element.attributes.key2.value).toBe(''); - expect(element.attributes.key2.namespaceURI).toBe(null); - expect(element.attributes.key2.specified).toBe(true); - expect(element.attributes.key2.ownerElement === element).toBe(true); - expect(element.attributes.key2.ownerDocument === document).toBe(true); + expect(element.attributes['key2'].name).toBe('key2'); + expect(element.attributes['key2'].value).toBe(''); + expect(element.attributes['key2'].namespaceURI).toBe(null); + expect(element.attributes['key2'].specified).toBe(true); + expect(element.attributes['key2'].ownerElement === element).toBe(true); + expect(element.attributes['key2'].ownerDocument === document).toBe(true); }); }); @@ -1345,19 +1345,19 @@ describe('Element', () => { expect((element.attributes[1]).ownerElement).toBe(element); expect((element.attributes[1]).ownerDocument).toBe(document); - expect((element.attributes.key1).name).toBe('key1'); - expect((element.attributes.key1).namespaceURI).toBe(NamespaceURI.svg); - expect((element.attributes.key1).value).toBe('value1'); - expect((element.attributes.key1).specified).toBe(true); - expect((element.attributes.key1).ownerElement).toBe(element); - expect((element.attributes.key1).ownerDocument).toBe(document); - - expect((element.attributes.key2).name).toBe('key2'); - expect((element.attributes.key2).namespaceURI).toBe(null); - expect((element.attributes.key2).value).toBe('value2'); - expect((element.attributes.key2).specified).toBe(true); - expect((element.attributes.key2).ownerElement).toBe(element); - expect((element.attributes.key2).ownerDocument).toBe(document); + expect((element.attributes['key1']).name).toBe('key1'); + expect((element.attributes['key1']).namespaceURI).toBe(NamespaceURI.svg); + expect((element.attributes['key1']).value).toBe('value1'); + expect((element.attributes['key1']).specified).toBe(true); + expect((element.attributes['key1']).ownerElement).toBe(element); + expect((element.attributes['key1']).ownerDocument).toBe(document); + + expect((element.attributes['key2']).name).toBe('key2'); + expect((element.attributes['key2']).namespaceURI).toBe(null); + expect((element.attributes['key2']).value).toBe('value2'); + expect((element.attributes['key2']).specified).toBe(true); + expect((element.attributes['key2']).ownerElement).toBe(element); + expect((element.attributes['key2']).ownerDocument).toBe(document); }); it('Sets an Attr node on an element.', () => { @@ -1387,19 +1387,19 @@ describe('Element', () => { expect((svg.attributes[1]).ownerElement).toBe(svg); expect((svg.attributes[1]).ownerDocument).toBe(document); - expect((svg.attributes.KEY1).name).toBe('KEY1'); - expect((svg.attributes.KEY1).namespaceURI).toBe(NamespaceURI.svg); - expect((svg.attributes.KEY1).value).toBe('value1'); - expect((svg.attributes.KEY1).specified).toBe(true); - expect((svg.attributes.KEY1).ownerElement).toBe(svg); - expect((svg.attributes.KEY1).ownerDocument).toBe(document); - - expect((svg.attributes.key2).name).toBe('key2'); - expect((svg.attributes.key2).namespaceURI).toBe(null); - expect((svg.attributes.key2).value).toBe('value2'); - expect((svg.attributes.key2).specified).toBe(true); - expect((svg.attributes.key2).ownerElement).toBe(svg); - expect((svg.attributes.key2).ownerDocument).toBe(document); + expect((svg.attributes['KEY1']).name).toBe('KEY1'); + expect((svg.attributes['KEY1']).namespaceURI).toBe(NamespaceURI.svg); + expect((svg.attributes['KEY1']).value).toBe('value1'); + expect((svg.attributes['KEY1']).specified).toBe(true); + expect((svg.attributes['KEY1']).ownerElement).toBe(svg); + expect((svg.attributes['KEY1']).ownerDocument).toBe(document); + + expect((svg.attributes['key2']).name).toBe('key2'); + expect((svg.attributes['key2']).namespaceURI).toBe(null); + expect((svg.attributes['key2']).value).toBe('value2'); + expect((svg.attributes['key2']).specified).toBe(true); + expect((svg.attributes['key2']).ownerElement).toBe(svg); + expect((svg.attributes['key2']).ownerDocument).toBe(document); }); }); } diff --git a/packages/happy-dom/test/xml-parser/XMLParser.test.ts b/packages/happy-dom/test/xml-parser/XMLParser.test.ts index 17d213807..e1448b650 100644 --- a/packages/happy-dom/test/xml-parser/XMLParser.test.ts +++ b/packages/happy-dom/test/xml-parser/XMLParser.test.ts @@ -80,25 +80,25 @@ describe('XMLParser', () => { true ); - expect((root.childNodes[0]).attributes.class.name).toBe('class'); - expect((root.childNodes[0]).attributes.class.value).toBe('class1 class2'); - expect((root.childNodes[0]).attributes.class.namespaceURI).toBe(null); - expect((root.childNodes[0]).attributes.class.specified).toBe(true); + expect((root.childNodes[0]).attributes['class'].name).toBe('class'); + expect((root.childNodes[0]).attributes['class'].value).toBe('class1 class2'); + expect((root.childNodes[0]).attributes['class'].namespaceURI).toBe(null); + expect((root.childNodes[0]).attributes['class'].specified).toBe(true); expect( - (root.childNodes[0]).attributes.class.ownerElement === root.childNodes[0] + (root.childNodes[0]).attributes['class'].ownerElement === root.childNodes[0] + ).toBe(true); + expect( + (root.childNodes[0]).attributes['class'].ownerDocument === document ).toBe(true); - expect((root.childNodes[0]).attributes.class.ownerDocument === document).toBe( - true - ); - expect((root.childNodes[0]).attributes.id.name).toBe('id'); - expect((root.childNodes[0]).attributes.id.value).toBe('id'); - expect((root.childNodes[0]).attributes.id.namespaceURI).toBe(null); - expect((root.childNodes[0]).attributes.id.specified).toBe(true); + expect((root.childNodes[0]).attributes['id'].name).toBe('id'); + expect((root.childNodes[0]).attributes['id'].value).toBe('id'); + expect((root.childNodes[0]).attributes['id'].namespaceURI).toBe(null); + expect((root.childNodes[0]).attributes['id'].specified).toBe(true); expect( - (root.childNodes[0]).attributes.id.ownerElement === root.childNodes[0] + (root.childNodes[0]).attributes['id'].ownerElement === root.childNodes[0] ).toBe(true); - expect((root.childNodes[0]).attributes.id.ownerDocument === document).toBe( + expect((root.childNodes[0]).attributes['id'].ownerDocument === document).toBe( true ); @@ -295,33 +295,33 @@ describe('XMLParser', () => { 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.html); - expect(svg.attributes.xmlns.specified).toBe(true); - expect(svg.attributes.xmlns.ownerElement === svg).toBe(true); - expect(svg.attributes.xmlns.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.html); + expect(svg.attributes['xmlns'].specified).toBe(true); + expect(svg.attributes['xmlns'].ownerElement === svg).toBe(true); + expect(svg.attributes['xmlns'].ownerDocument === document).toBe(true); expect(new XMLSerializer().serializeToString(root).replace(/[\s]/gm, '')).toBe( ` From 0e5862e019328befabe05a66d533a974685c2f1e Mon Sep 17 00:00:00 2001 From: David Ortner Date: Tue, 18 Oct 2022 23:38:59 +0200 Subject: [PATCH 06/16] #308@trivial: Fixes failing unit test. --- packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts index 9b1214a66..fefa0a226 100644 --- a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts +++ b/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts @@ -102,7 +102,7 @@ describe('NamedNodeMap', () => { it('Replaces an attribute when existing.', () => { element.setAttribute('key', 'value1'); - const attr = attributes.getNamedItem('key'); + const attr = document.createAttributeNS('namespace', 'key'); attr.value = 'value2'; const replaced = attributes.setNamedItem(attr); @@ -127,7 +127,7 @@ describe('NamedNodeMap', () => { it('Replaces an attribute when existing.', () => { element.setAttributeNS('namespace', 'key', 'value1'); - const attr = attributes.getNamedItemNS('namespace', 'key'); + const attr = document.createAttributeNS('namespace', 'key'); attr.value = 'value2'; const replaced = attributes.setNamedItemNS(attr); From 25dadff5ec2b1974dbea558047dfbef1a60a6b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Ledentu?= Date: Thu, 20 Oct 2022 14:48:56 +0200 Subject: [PATCH 07/16] #627@patch: Fix location.href initialization in Window. --- packages/happy-dom/src/window/Window.ts | 22 +++++++++++++------ packages/happy-dom/test/window/Window.test.ts | 12 ++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index ac23a14f1..8f988f74d 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -252,20 +252,20 @@ export default class Window extends EventTarget implements IWindow { // Public Properties public readonly document: Document; - public readonly customElements: CustomElementRegistry = new CustomElementRegistry(); - public readonly location = new Location(); - public readonly history = new History(); - public readonly navigator = new Navigator(); + public readonly customElements: CustomElementRegistry; + public readonly location: Location; + public readonly history: History; + public readonly navigator: Navigator; public readonly console = console; public readonly self = this; public readonly top = this; public readonly parent = this; public readonly window = this; public readonly globalThis = this; - public readonly screen = new Screen(); + public readonly screen: Screen; public readonly devicePixelRatio = 1; - public readonly sessionStorage = new Storage(); - public readonly localStorage = new Storage(); + public readonly sessionStorage: Storage; + public readonly localStorage: Storage; public readonly performance = PerfHooks.performance; public readonly innerWidth: number; public readonly innerHeight: number; @@ -350,6 +350,14 @@ export default class Window extends EventTarget implements IWindow { constructor(options?: { innerWidth?: number; innerHeight?: number; url?: string }) { super(); + this.customElements = new CustomElementRegistry(); + this.location = new Location(); + this.navigator = new Navigator(); + this.history = new History(); + this.screen = new Screen(); + this.sessionStorage = new Storage(); + this.localStorage = new Storage(); + this.innerWidth = options?.innerWidth ? options.innerWidth : 0; this.innerHeight = options?.innerHeight ? options.innerHeight : 0; diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 8c8a92837..188e049af 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -79,6 +79,18 @@ describe('Window', () => { expect(secondComment.ownerDocument === secondWindow.document).toBe(true); expect(thirdComment.ownerDocument === thirdWindow.document).toBe(true); }); + + it('Initializes by using given options', () => { + const windowWithOptions = new Window({ + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080' + }); + + expect(windowWithOptions.innerWidth).toBe(1024); + expect(windowWithOptions.innerHeight).toBe(768); + expect(windowWithOptions.location.href).toBe('http://localhost:8080/'); + }); }); describe('get Object()', () => { From 1e3d0df771f324efe69696c2c7268876f30a788e Mon Sep 17 00:00:00 2001 From: Matthew Lieder Date: Sun, 23 Oct 2022 14:13:28 -0500 Subject: [PATCH 08/16] #633@patch: Fix getElementByTagName doesn't support wildcard searches (improves jQuery support). --- .../nodes/parent-node/ParentNodeUtility.ts | 6 ++- .../parent-node/ParentNodeUtility.test.ts | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts index 9de04377d..755721541 100644 --- a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts +++ b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts @@ -97,9 +97,10 @@ export default class ParentNodeUtility { ): IHTMLCollection { const upperTagName = tagName.toUpperCase(); const matches = HTMLCollectionFactory.create(); + const includeAll = tagName === '*'; for (const child of parentNode.children) { - if (child.tagName === upperTagName) { + if (includeAll || child.tagName === upperTagName) { matches.push(child); } for (const match of this.getElementsByTagName(child, tagName)) { @@ -125,9 +126,10 @@ export default class ParentNodeUtility { ): IHTMLCollection { const upperTagName = tagName.toUpperCase(); const matches = HTMLCollectionFactory.create(); + const includeAll = tagName === '*'; for (const child of parentNode.children) { - if (child.tagName === upperTagName && child.namespaceURI === namespaceURI) { + if ((includeAll || child.tagName === upperTagName) && child.namespaceURI === namespaceURI) { matches.push(child); } for (const match of this.getElementsByTagNameNS(child, namespaceURI, tagName)) { diff --git a/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts b/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts index 0e2aac92e..3a412f1ec 100644 --- a/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts +++ b/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts @@ -155,6 +155,27 @@ describe('ParentNodeUtility', () => { div4 ]); }); + + it('Returns all elements when tag name is *.', () => { + const parent = document.createElement('div'); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + parent.appendChild(div1); + div1.appendChild(div2); + div2.appendChild(span1); + span1.appendChild(div3); + div3.appendChild(span2); + div3.appendChild(span3); + span3.appendChild(div4); + + expect(ParentNodeUtility.getElementsByTagName(parent, '*').length).toEqual(7); + }); }); describe('getElementsByTagNameNS()', () => { @@ -164,7 +185,7 @@ describe('ParentNodeUtility', () => { const div2 = document.createElement('div'); const div3 = document.createElementNS(NamespaceURI.svg, 'div'); const div4 = document.createElement('div'); - const span1 = document.createElement('span'); + const span1 = document.createElementNS(NamespaceURI.svg, 'span'); const span2 = document.createElement('span'); const span3 = document.createElement('span'); @@ -181,6 +202,29 @@ describe('ParentNodeUtility', () => { div3 ]); }); + + it('Returns all elements when tag name is *.', () => { + const parent = document.createElement('div'); + const div1 = document.createElementNS(NamespaceURI.svg, 'div'); + const div2 = document.createElement('div'); + const div3 = document.createElementNS(NamespaceURI.svg, 'div'); + const div4 = document.createElement('div'); + const span1 = document.createElementNS(NamespaceURI.svg, 'span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + parent.appendChild(div1); + div1.appendChild(div2); + div2.appendChild(span1); + span1.appendChild(div3); + div3.appendChild(span2); + div3.appendChild(span3); + span3.appendChild(div4); + + expect( + ParentNodeUtility.getElementsByTagNameNS(parent, NamespaceURI.svg, '*').length + ).toEqual(3); + }); }); describe('getElementByTagName()', () => { From 67e8bca9d201902441e0400cfe0e7aa18a96c41a Mon Sep 17 00:00:00 2001 From: David Ortner Date: Tue, 25 Oct 2022 01:44:06 +0200 Subject: [PATCH 09/16] #635@patch: Multiples fixes regarding how HTMLSelectElement.selectedIndex is handled. --- .../html-option-element/HTMLOptionElement.ts | 40 +++++++- .../HTMLOptionsCollection.ts | 20 ++-- .../html-select-element/HTMLSelectElement.ts | 26 ++++-- .../HTMLOptionElement.test.ts | 91 +++++++++++++++---- .../HTMLOptionsCollection.test.ts | 59 ++++++------ .../HTMLSelectElement.test.ts | 19 +++- 6 files changed, 180 insertions(+), 75 deletions(-) diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts index a2b7a615b..0f1dbdae7 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts @@ -1,6 +1,7 @@ import HTMLElement from '../html-element/HTMLElement'; import IHTMLElement from '../html-element/IHTMLElement'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement'; +import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement'; import IHTMLOptionElement from './IHTMLOptionElement'; /** @@ -58,7 +59,20 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Selected. */ public get selected(): boolean { - return this.getAttributeNS(null, 'selected') !== null; + const parentNode = this.parentNode; + + if (parentNode?.tagName === 'SELECT') { + let index = -1; + for (let i = 0; i < parentNode.options.length; i++) { + if (parentNode.options[i] === this) { + index = i; + break; + } + } + return index !== -1 && parentNode.options.selectedIndex === index; + } + + return false; } /** @@ -67,10 +81,26 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @param selected Selected. */ public set selected(selected: boolean) { - if (!selected) { - this.removeAttributeNS(null, 'selected'); - } else { - this.setAttributeNS(null, 'selected', ''); + const parentNode = this.parentNode; + if (parentNode?.tagName === 'SELECT') { + if (selected) { + let index = -1; + + for (let i = 0; i < parentNode.options.length; i++) { + if (parentNode.options[i] === this) { + index = i; + break; + } + } + + if (index !== -1) { + parentNode.options.selectedIndex = index; + } + } else if (parentNode.options.length) { + parentNode.options.selectedIndex = 0; + } else { + parentNode.options.selectedIndex = -1; + } } } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts index 9395783e6..2d81159c0 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts @@ -2,7 +2,6 @@ import DOMException from '../../exception/DOMException'; import HTMLCollection from '../element/HTMLCollection'; import IHTMLOptGroupElement from '../html-opt-group-element/IHTMLOptGroupElement'; import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement'; -import HTMLOptionElement from './HTMLOptionElement'; import IHTMLOptionElement from './IHTMLOptionElement'; import IHTMLOptionsCollection from './IHTMLOptionsCollection'; @@ -17,6 +16,7 @@ export default class HTMLOptionsCollection implements IHTMLOptionsCollection { private _selectElement: IHTMLSelectElement; + private _selectedIndex = -1; /** * @@ -34,14 +34,7 @@ export default class HTMLOptionsCollection * @returns SelectedIndex. */ public get selectedIndex(): number { - for (let i = 0; i < this.length; i++) { - const item = this[i]; - if (item instanceof HTMLOptionElement && item.selected) { - return i; - } - } - - return -1; + return this._selectedIndex; } /** @@ -50,10 +43,11 @@ export default class HTMLOptionsCollection * @param selectedIndex SelectedIndex. */ public set selectedIndex(selectedIndex: number) { - for (let i = 0; i < this.length; i++) { - const item = this[i]; - if (item instanceof HTMLOptionElement) { - this[i].selected = i === selectedIndex; + if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { + if (selectedIndex >= 0 && selectedIndex < this.length) { + this._selectedIndex = selectedIndex; + } else { + this._selectedIndex = -1; } } } diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts index 50ead16f0..b4c0e13a3 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts @@ -1,5 +1,3 @@ -import DOMException from '../../exception/DOMException'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum'; import HTMLElement from '../html-element/HTMLElement'; import IHTMLElement from '../html-element/IHTMLElement'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement'; @@ -204,13 +202,6 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param value Value. */ public set selectedIndex(value: number) { - if (value > this.options.length - 1 || value < -1) { - throw new DOMException( - 'Select elements selected index must be valid', - DOMExceptionNameEnum.indexSizeError - ); - } - this.options.selectedIndex = value; } @@ -296,6 +287,10 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (element.tagName === 'OPTION' || element.tagName === 'OPTGROUP') { this.options.push(element); + + if (this.options.length === 1) { + this.options.selectedIndex = 0; + } } this._updateIndexProperties(previousLength, this.options.length); @@ -337,6 +332,10 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec } else { this.options.push(newElement); } + + if (this.options.length === 1) { + this.options.selectedIndex = 0; + } } this._updateIndexProperties(previousLength, this.options.length); @@ -355,9 +354,18 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (element.tagName === 'OPTION' || element.tagName === 'OPTION') { const index = this.options.indexOf(node); + if (index !== -1) { this.options.splice(index, 1); } + + if (this.options.selectedIndex >= this.options.length) { + this.options.selectedIndex = this.options.length - 1; + } + + if (!this.options.length) { + this.options.selectedIndex = -1; + } } this._updateIndexProperties(previousLength, this.options.length); diff --git a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts index 8db4f0778..dc8769636 100644 --- a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts +++ b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts @@ -1,16 +1,17 @@ import Window from '../../../src/window/Window'; import Document from '../../../src/nodes/document/Document'; -import HTMLOptionElement from '../../../src/nodes/html-option-element/HTMLOptionElement'; +import IHTMLOptionElement from '../../../src/nodes/html-option-element/IHTMLOptionElement'; +import IHTMLSelectElement from '../../../src/nodes/html-select-element/IHTMLSelectElement'; describe('HTMLOptionElement', () => { let window: Window; let document: Document; - let element: HTMLOptionElement; + let element: IHTMLOptionElement; beforeEach(() => { window = new Window(); document = window.document; - element = document.createElement('option'); + element = document.createElement('option'); }); describe('Object.prototype.toString', () => { @@ -33,20 +34,78 @@ describe('HTMLOptionElement', () => { }); }); - for (const property of ['disabled', 'selected']) { - describe(`get ${property}()`, () => { - it('Returns attribute value.', () => { - expect(element[property]).toBe(false); - element.setAttribute(property, ''); - expect(element[property]).toBe(true); - }); + describe('get disabled()', () => { + it('Returns the attribute "disabled".', () => { + element.setAttribute('disabled', ''); + expect(element.disabled).toBe(true); }); + }); - describe(`set ${property}()`, () => { - it('Sets attribute value.', () => { - element[property] = true; - expect(element.getAttribute(property)).toBe(''); - }); + describe('set disabled()', () => { + it('Sets the attribute "disabled".', () => { + element.disabled = true; + expect(element.getAttribute('disabled')).toBe(''); }); - } + }); + + describe('get selected()', () => { + it('Returns the selected state of the option.', () => { + const select = document.createElement('select'); + const option1 = document.createElement('option'); + const option2 = document.createElement('option'); + + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(false); + + select.appendChild(option1); + select.appendChild(option2); + + expect(option1.selected).toBe(true); + expect(option2.selected).toBe(false); + expect(option1.getAttribute('selected')).toBe(null); + expect(option2.getAttribute('selected')).toBe(null); + + select.options.selectedIndex = 1; + + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(true); + expect(option1.getAttribute('selected')).toBe(null); + expect(option2.getAttribute('selected')).toBe(null); + + select.options.selectedIndex = -1; + + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(false); + }); + }); + + describe('set selected()', () => { + it('Sets the selected state of the option.', () => { + const select = document.createElement('select'); + const option1 = document.createElement('option'); + const option2 = document.createElement('option'); + + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(false); + + option1.selected = true; + + expect(select.selectedIndex).toBe(-1); + + select.appendChild(option1); + select.appendChild(option2); + + option1.selected = true; + + expect(select.selectedIndex).toBe(0); + + option2.selected = true; + + expect(select.selectedIndex).toBe(1); + + option2.selected = false; + + expect(select.selectedIndex).toBe(0); + }); + }); }); diff --git a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts index f9ea7f3a0..9c63dcdbd 100644 --- a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts +++ b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts @@ -3,7 +3,7 @@ import IWindow from '../../../src/window/IWindow'; import IDocument from '../../../src/nodes/document/IDocument'; import HTMLSelectElement from '../../../src/nodes/html-select-element/HTMLSelectElement'; import HTMLOptionElement from '../../../src/nodes/html-option-element/HTMLOptionElement'; -import { DOMException } from '../../../src'; +import DOMException from '../../../src/exception/DOMException'; describe('HTMLOptionsCollection', () => { let window: IWindow; @@ -18,57 +18,51 @@ describe('HTMLOptionsCollection', () => { jest.restoreAllMocks(); }); - describe('get selectedindex()', () => { - it('Returns the index of the first option element in the list of options in tree order that has its selectedness set to true.', () => { - const select = document.createElement('select'); - const option1 = document.createElement('option'); - const option2 = document.createElement('option'); - option1.selected = true; - option1.value = 'option1'; - option2.value = 'option2'; - select.appendChild(option1); - select.appendChild(option2); - - expect(select.options.selectedIndex).toBe(0); - }); - + describe('get selectedIndex()', () => { it('Returns -1 if there are no options.', () => { const select = document.createElement('select'); expect(select.options.selectedIndex).toBe(-1); }); - it('Returns -1 if no option is selected.', () => { + it('Returns 0 by default.', () => { const select = document.createElement('select'); const option1 = document.createElement('option'); const option2 = document.createElement('option'); + option1.value = 'option1'; option2.value = 'option2'; + select.appendChild(option1); select.appendChild(option2); - expect(select.options.selectedIndex).toBe(-1); + expect(select.options.selectedIndex).toBe(0); }); }); - describe('set selectedindex()', () => { + describe('set selectedIndex()', () => { it('Updates option.selected', () => { const select = document.createElement('select'); - select.appendChild(document.createElement('option')); - select.appendChild(document.createElement('option')); - document.body.appendChild(select); + const option1 = document.createElement('option'); + const option2 = document.createElement('option'); + + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(false); + + select.appendChild(option1); + select.appendChild(option2); - expect((select.options[0]).selected).toBe(false); - expect((select.options[1]).selected).toBe(false); + expect(option1.selected).toBe(true); + expect(option2.selected).toBe(false); select.options.selectedIndex = 1; - expect((select.options[0]).selected).toBe(false); - expect((select.options[1]).selected).toBe(true); + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(true); select.options.selectedIndex = -1; - expect((select.options[0]).selected).toBe(false); - expect((select.options[1]).selected).toBe(false); + expect(option1.selected).toBe(false); + expect(option2.selected).toBe(false); }); }); @@ -134,16 +128,21 @@ describe('HTMLOptionsCollection', () => { const select = document.createElement('select'); const option = document.createElement('option'); const option2 = document.createElement('option'); + + expect(select.options.selectedIndex).toBe(-1); + select.appendChild(option); select.appendChild(option2); - document.body.appendChild(select); - expect(select.options.selectedIndex).toBe(-1); + + expect(select.options.selectedIndex).toBe(0); select.options.selectedIndex = 1; expect(select.options.selectedIndex).toBe(1); - // No option is selected after removing the selected option select.options.remove(1); + expect(select.options.selectedIndex).toBe(0); + + select.options.remove(0); expect(select.options.selectedIndex).toBe(-1); }); }); diff --git a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts index c2f6f0272..f7705efdd 100644 --- a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts +++ b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts @@ -70,6 +70,10 @@ describe('HTMLSelectElement', () => { element.appendChild(option1); element.appendChild(option2); + expect(element.value).toBe('option1'); + + element.selectedIndex = -1; + expect(element.value).toBe(''); }); }); @@ -87,8 +91,6 @@ describe('HTMLSelectElement', () => { expect(element.options.selectedIndex).toBe(0); }); - - it('Trims and removes new lines.', () => {}); }); for (const property of ['disabled', 'autofocus', 'required', 'multiple']) { @@ -149,6 +151,19 @@ describe('HTMLSelectElement', () => { element.selectedIndex = 1; expect(element.options.selectedIndex).toBe(1); }); + + it('Ignores invalid values gracefully.', () => { + element.appendChild(document.createElement('option')); + element.appendChild(document.createElement('option')); + + expect(element.options.selectedIndex).toBe(0); + + element.selectedIndex = undefined; + expect(element.options.selectedIndex).toBe(0); + + element.selectedIndex = 1000; + expect(element.options.selectedIndex).toBe(-1); + }); }); describe(`add()`, () => { From ecef36ef1a1840dd5735fbb0db0444817d916d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Ledentu?= Date: Thu, 20 Oct 2022 09:54:23 +0200 Subject: [PATCH 10/16] #628@patch: Handle style setter in HTMLElement. --- .../AbstractCSSStyleDeclaration.ts | 22 ++++++--------- .../src/nodes/html-element/HTMLElement.ts | 10 +++++++ .../src/nodes/html-element/IHTMLElement.ts | 4 ++- .../declaration/CSSStyleDeclaration.test.ts | 2 +- .../nodes/html-element/HTMLElement.test.ts | 28 +++++++++++++++++++ 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts index af58ea6ab..22547789d 100644 --- a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts +++ b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts @@ -77,21 +77,17 @@ export default abstract class AbstractCSSStyleDeclaration { if (this._ownerElement) { const style = new CSSStyleDeclarationPropertyManager({ cssText }); - if (!style.size()) { - delete this._ownerElement['_attributes']['style']; - } else { - if (!this._ownerElement['_attributes']['style']) { - Attr._ownerDocument = this._ownerElement.ownerDocument; - this._ownerElement['_attributes']['style'] = new Attr(); - this._ownerElement['_attributes']['style'].name = 'style'; - } - - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; - } + if (!this._ownerElement['_attributes']['style']) { + Attr._ownerDocument = this._ownerElement.ownerDocument; + this._ownerElement['_attributes']['style'] = new Attr(); + this._ownerElement['_attributes']['style'].name = 'style'; + } - this._ownerElement['_attributes']['style'].value = style.toString(); + if (this._ownerElement.isConnected) { + this._ownerElement.ownerDocument['_cacheID']++; } + + this._ownerElement['_attributes']['style'].value = style.toString(); } else { this._style = new CSSStyleDeclarationPropertyManager({ cssText }); } diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index beb210e7a..2e5c30308 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -200,6 +200,16 @@ export default class HTMLElement extends Element implements IHTMLElement { return this._style; } + /** + * Sets style. + * + * @param cssText Style as text. + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style#setting_styles + */ + public set style(cssText: string | CSSStyleDeclaration) { + this.setAttribute('style', typeof cssText === 'string' ? cssText : ''); + } + /** * Returns data set. * diff --git a/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts b/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts index f79f8b019..35ab764da 100644 --- a/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts @@ -9,7 +9,6 @@ import IElement from '../element/IElement'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement. */ export default interface IHTMLElement extends IElement { - style: CSSStyleDeclaration; dataset: { [key: string]: string }; tabIndex: number; offsetHeight: number; @@ -48,6 +47,9 @@ export default interface IHTMLElement extends IElement { ontransitionrun: (event: Event) => void | null; ontransitionstart: (event: Event) => void | null; + get style(): CSSStyleDeclaration; + set style(cssText: CSSStyleDeclaration | string); + /** * Triggers a click event. */ diff --git a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts index 1d3a13020..ddf4b572d 100644 --- a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts +++ b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts @@ -1978,7 +1978,7 @@ describe('CSSStyleDeclaration', () => { declaration.cssText = ''; - expect(element.getAttribute('style')).toBe(null); + expect(element.getAttribute('style')).toBe(''); }); }); diff --git a/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts b/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts index 9d2053311..b8cb69859 100644 --- a/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts +++ b/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts @@ -239,6 +239,34 @@ describe('HTMLElement', () => { }); }); + describe('set style()', () => { + it('Sets the value of the style.cssText property.', () => { + element.style = 'border-radius: 2px; padding: 2px;'; + + expect(element.style.cssText).toEqual('border-radius: 2px; padding: 2px;'); + expect(element.style.borderRadius).toEqual('2px'); + expect(element.style.padding).toEqual('2px'); + expect(element.getAttribute('style')).toEqual('border-radius: 2px; padding: 2px;'); + expect(element.outerHTML).toEqual('
'); + + element.style = ''; + + expect(element.style.cssText).toEqual(''); + expect(element.style.borderRadius).toEqual(''); + expect(element.style.padding).toEqual(''); + expect(element.getAttribute('style')).toEqual(''); + expect(element.outerHTML).toEqual('
'); + + element.style = null; + + expect(element.style.cssText).toEqual(''); + expect(element.style.borderRadius).toEqual(''); + expect(element.style.padding).toEqual(''); + expect(element.getAttribute('style')).toEqual(''); + expect(element.outerHTML).toEqual('
'); + }); + }); + describe('get dataset()', () => { it('Returns a Proxy behaving like an object that can add, remove, set and get element attributes prefixed with "data-".', () => { element.setAttribute('test-alpha', 'value1'); From 58d07518f1dd1f58a2cd6f86f450760844359aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Ledentu?= Date: Tue, 25 Oct 2022 09:59:46 +0200 Subject: [PATCH 11/16] #628@trivial: Replace setAttribute by cssText assignment in style setter. --- packages/happy-dom/src/nodes/html-element/HTMLElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index 2e5c30308..bb1a93872 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -207,7 +207,7 @@ export default class HTMLElement extends Element implements IHTMLElement { * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style#setting_styles */ public set style(cssText: string | CSSStyleDeclaration) { - this.setAttribute('style', typeof cssText === 'string' ? cssText : ''); + this.style.cssText = typeof cssText === 'string' ? cssText : ''; } /** From 8d02d8c9bc57b05c6c7fd3dfe19d92a7783caeb7 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 26 Oct 2022 01:34:34 +0200 Subject: [PATCH 12/16] #637@patch: Fixes problem with HTMLSelectElement.selectedIndex not reflecting the select attribute set on options. --- .../happy-dom/src/nodes/element/Element.ts | 18 ++- .../happy-dom/src/nodes/element/IElement.ts | 6 +- .../src/nodes/html-element/HTMLElement.ts | 15 +- .../html-option-element/HTMLOptionElement.ts | 104 +++++++++----- .../HTMLOptionsCollection.ts | 11 +- .../html-select-element/HTMLSelectElement.ts | 136 ++++++++++++++---- .../src/nodes/svg-element/SVGElement.ts | 11 +- .../HTMLSelectElement.test.ts | 20 +++ 8 files changed, 225 insertions(+), 96 deletions(-) diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 0e90932d0..a7f3140dc 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -919,8 +919,17 @@ export default class Element extends Node implements IElement { * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { + const removedAttribute = this._attributes[attribute.name]; + + if (removedAttribute !== attribute) { + throw new DOMException( + `Failed to execute 'removeAttributeNode' on 'Element': The node provided is owned by another element.` + ); + } + delete this._attributes[attribute.name]; if (this.isConnected) { @@ -954,15 +963,18 @@ export default class Element extends Node implements IElement { } } } + + return attribute; } /** * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - public removeAttributeNodeNS(attribute: IAttr): void { - this.removeAttributeNode(attribute); + public removeAttributeNodeNS(attribute: IAttr): IAttr { + return this.removeAttributeNode(attribute); } /** diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index 7649cd0bf..b3fc6ff57 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -260,15 +260,17 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - removeAttributeNode(attribute: IAttr): void; + removeAttributeNode(attribute: IAttr): IAttr; /** * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - removeAttributeNodeNS(attribute: IAttr): void; + removeAttributeNodeNS(attribute: IAttr): IAttr; /** * Clones a node. diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index bb1a93872..262eb88a5 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -421,11 +421,7 @@ export default class HTMLElement extends Element implements IHTMLElement { } /** - * The setAttributeNode() method adds a new Attr node to the specified element. - * * @override - * @param attribute Attribute. - * @returns Replaced attribute. */ public setAttributeNode(attribute: IAttr): IAttr { const replacedAttribute = super.setAttributeNode(attribute); @@ -438,25 +434,20 @@ export default class HTMLElement extends Element implements IHTMLElement { } /** - * Removes an Attr node. - * * @override - * @param attribute Attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { super.removeAttributeNode(attribute); if (attribute.name === 'style' && this._style) { this._style.cssText = ''; } + + return attribute; } /** - * Clones a node. - * * @override - * @param [deep=false] "true" to clone deep. - * @returns Cloned node. */ public cloneNode(deep = false): IHTMLElement { const clone = super.cloneNode(deep); diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts index 0f1dbdae7..a692e5e00 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts @@ -1,7 +1,8 @@ +import IAttr from '../attr/IAttr'; import HTMLElement from '../html-element/HTMLElement'; import IHTMLElement from '../html-element/IHTMLElement'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement'; -import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement'; +import HTMLSelectElement from '../html-select-element/HTMLSelectElement'; import IHTMLOptionElement from './IHTMLOptionElement'; /** @@ -12,6 +13,8 @@ import IHTMLOptionElement from './IHTMLOptionElement'; */ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptionElement { public _index: number; + public _selectedness = false; + public _dirtyness = false; /** * Returns inner text, which is the rendered appearance of text. @@ -59,20 +62,7 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Selected. */ public get selected(): boolean { - const parentNode = this.parentNode; - - if (parentNode?.tagName === 'SELECT') { - let index = -1; - for (let i = 0; i < parentNode.options.length; i++) { - if (parentNode.options[i] === this) { - index = i; - break; - } - } - return index !== -1 && parentNode.options.selectedIndex === index; - } - - return false; + return this._selectedness; } /** @@ -81,26 +71,13 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @param selected Selected. */ public set selected(selected: boolean) { - const parentNode = this.parentNode; - if (parentNode?.tagName === 'SELECT') { - if (selected) { - let index = -1; - - for (let i = 0; i < parentNode.options.length; i++) { - if (parentNode.options[i] === this) { - index = i; - break; - } - } - - if (index !== -1) { - parentNode.options.selectedIndex = index; - } - } else if (parentNode.options.length) { - parentNode.options.selectedIndex = 0; - } else { - parentNode.options.selectedIndex = -1; - } + const selectElement = this._getSelectElement(); + + this._dirtyness = true; + this._selectedness = Boolean(selected); + + if (selectElement) { + selectElement._resetOptionSelectednes(this._selectedness ? this : null); } } @@ -143,4 +120,61 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio public set value(value: string) { this.setAttributeNS(null, 'value', value); } + + /** + * @override + */ + public setAttributeNode(attribute: IAttr): IAttr { + const replacedAttribute = super.setAttributeNode(attribute); + + if ( + !this._dirtyness && + attribute.name === 'selected' && + replacedAttribute?.value !== attribute.value + ) { + const selectElement = this._getSelectElement(); + + this._selectedness = true; + + if (selectElement) { + selectElement._resetOptionSelectednes(this); + } + } + + return replacedAttribute; + } + + /** + * @override + */ + public removeAttributeNode(attribute: IAttr): IAttr { + super.removeAttributeNode(attribute); + + if (!this._dirtyness && attribute.name === 'selected') { + const selectElement = this._getSelectElement(); + + this._selectedness = false; + + if (selectElement) { + selectElement._resetOptionSelectednes(); + } + } + + return attribute; + } + + /** + * Returns select element. + * + * @returns Select element. + */ + private _getSelectElement(): HTMLSelectElement { + const parentNode = this.parentNode; + if (parentNode?.tagName === 'SELECT') { + return parentNode; + } + if ((parentNode?.parentNode)?.tagName === 'SELECT') { + return parentNode.parentNode; + } + } } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts index 2d81159c0..8226dde2a 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts @@ -16,7 +16,6 @@ export default class HTMLOptionsCollection implements IHTMLOptionsCollection { private _selectElement: IHTMLSelectElement; - private _selectedIndex = -1; /** * @@ -34,7 +33,7 @@ export default class HTMLOptionsCollection * @returns SelectedIndex. */ public get selectedIndex(): number { - return this._selectedIndex; + return this._selectElement.selectedIndex; } /** @@ -43,13 +42,7 @@ export default class HTMLOptionsCollection * @param selectedIndex SelectedIndex. */ public set selectedIndex(selectedIndex: number) { - if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { - if (selectedIndex >= 0 && selectedIndex < this.length) { - this._selectedIndex = selectedIndex; - } else { - this._selectedIndex = -1; - } - } + this._selectElement.selectedIndex = selectedIndex; } /** diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts index b4c0e13a3..b7437746c 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts @@ -167,13 +167,14 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get value(): string { - if (this.options.selectedIndex === -1) { - return ''; + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + if (option._selectedness) { + return option.value; + } } - const option = this.options[this.options.selectedIndex]; - - return option instanceof HTMLOptionElement ? option.value : ''; + return ''; } /** @@ -182,9 +183,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param value Value. */ public set value(value: string) { - this.options.selectedIndex = this.options.findIndex( - (o) => o instanceof HTMLOptionElement && o.value === value - ); + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + if (option.value === value) { + option._selectedness = true; + option._dirtyness = true; + } else { + option._selectedness = false; + } + } } /** @@ -193,16 +200,31 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get selectedIndex(): number { - return this.options.selectedIndex; + for (let i = 0, max = this.options.length; i < max; i++) { + if ((this.options[i])._selectedness) { + return i; + } + } + return -1; } /** * Sets value. * - * @param value Value. + * @param selectedIndex Selected index. */ - public set selectedIndex(value: number) { - this.options.selectedIndex = value; + public set selectedIndex(selectedIndex: number) { + if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { + for (let i = 0, max = this.options.length; i < max; i++) { + (this.options[i])._selectedness = false; + } + + const selectedOption = this.options[selectedIndex]; + if (selectedOption) { + selectedOption._selectedness = true; + selectedOption._dirtyness = true; + } + } } /** @@ -287,13 +309,10 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (element.tagName === 'OPTION' || element.tagName === 'OPTGROUP') { this.options.push(element); - - if (this.options.length === 1) { - this.options.selectedIndex = 0; - } } this._updateIndexProperties(previousLength, this.options.length); + this._resetOptionSelectednes(); } return super.appendChild(node); @@ -332,15 +351,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec } else { this.options.push(newElement); } - - if (this.options.length === 1) { - this.options.selectedIndex = 0; - } } this._updateIndexProperties(previousLength, this.options.length); } + if (newNode.nodeType === NodeTypeEnum.elementNode) { + this._resetOptionSelectednes(); + } + return returnValue; } @@ -358,20 +377,83 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (index !== -1) { this.options.splice(index, 1); } + } - if (this.options.selectedIndex >= this.options.length) { - this.options.selectedIndex = this.options.length - 1; + this._updateIndexProperties(previousLength, this.options.length); + this._resetOptionSelectednes(); + } + + return super.removeChild(node); + } + + /** + * Resets the option selectedness. + * + * Based on: + * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLSelectElement-impl.js + * + * @param [newOption] Optional new option element to be selected. + * @see https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm + */ + public _resetOptionSelectednes(newOption?: IHTMLOptionElement): void { + if (this.hasAttributeNS(null, 'multiple')) { + return; + } + + const selected: HTMLOptionElement[] = []; + + for (let i = 0, max = this.options.length; i < max; i++) { + if (newOption) { + (this.options[i])._selectedness = this.options[i] === newOption; + } + + if ((this.options[i])._selectedness) { + selected.push(this.options[i]); + } + } + + const size = this._getDisplaySize(); + + if (size === 1 && !selected.length) { + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + + let disabled = option.hasAttributeNS(null, 'disabled'); + const parentNode = option.parentNode; + if ( + parentNode && + parentNode.nodeType === NodeTypeEnum.elementNode && + parentNode.tagName === 'OPTGROUP' && + parentNode.hasAttributeNS(null, 'disabled') + ) { + disabled = true; } - if (!this.options.length) { - this.options.selectedIndex = -1; + if (!disabled) { + option._selectedness = true; + break; } } - - this._updateIndexProperties(previousLength, this.options.length); + } else if (selected.length >= 2) { + for (let i = 0, max = this.options.length; i < max; i++) { + (this.options[i])._selectedness = i === selected.length - 1; + } } + } - return super.removeChild(node); + /** + * Returns display size. + * + * @returns Display size. + */ + protected _getDisplaySize(): number { + if (this.hasAttributeNS(null, 'size')) { + const size = parseInt(this.getAttributeNS(null, 'size')); + if (!isNaN(size) && size >= 0) { + return size; + } + } + return this.hasAttributeNS(null, 'multiple') ? 4 : 1; } /** diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts index 12136ecbd..664e0c10d 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts @@ -74,11 +74,7 @@ export default class SVGElement extends Element implements ISVGElement { } /** - * The setAttributeNode() method adds a new Attr node to the specified element. - * * @override - * @param attribute Attribute. - * @returns Replaced attribute. */ public setAttributeNode(attribute: IAttr): IAttr { const replacedAttribute = super.setAttributeNode(attribute); @@ -91,16 +87,15 @@ export default class SVGElement extends Element implements ISVGElement { } /** - * Removes an Attr node. - * * @override - * @param attribute Attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { super.removeAttributeNode(attribute); if (attribute.name === 'style' && this._style) { this._style.cssText = ''; } + + return attribute; } } diff --git a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts index f7705efdd..d86ebc6b9 100644 --- a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts +++ b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts @@ -137,6 +137,26 @@ describe('HTMLSelectElement', () => { element.options.selectedIndex = 1; expect(element.selectedIndex).toBe(1); }); + + it('Returns option with "selected" attribute is defined.', () => { + const option1 = document.createElement('option'); + const option2 = document.createElement('option'); + + option2.setAttribute('selected', ''); + + element.appendChild(option1); + element.appendChild(option2); + + expect(element.selectedIndex).toBe(1); + + option1.setAttribute('selected', ''); + + expect(element.selectedIndex).toBe(0); + + option2.removeAttribute('selected'); + + expect(element.selectedIndex).toBe(0); + }); }); describe(`set selectedIndex()`, () => { From c355aecd59e90cdfbe44cd507eda83bb6029ed2c Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 26 Oct 2022 01:42:37 +0200 Subject: [PATCH 13/16] #639@patch: Element.innerHTML should be able to handle other types than string. --- .../happy-dom/src/xml-parser/XMLParser.ts | 120 +++++++++--------- .../test/xml-parser/XMLParser.test.ts | 14 ++ 2 files changed, 76 insertions(+), 58 deletions(-) diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index 98ed92be7..9cb8e787d 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -42,76 +42,80 @@ export default class XMLParser { let lastTextIndex = 0; let match: RegExpExecArray; - while ((match = markupRegexp.exec(data))) { - const tagName = match[2].toLowerCase(); - const isStartTag = !match[1]; + if (data !== null && data !== undefined) { + data = String(data); - if (parent && match.index !== lastTextIndex) { - const text = data.substring(lastTextIndex, match.index); - this.appendTextAndCommentNodes(document, parent, text); - } - - if (isStartTag) { - const namespaceURI = - tagName === 'svg' - ? NamespaceURI.svg - : (parent).namespaceURI || NamespaceURI.html; - const newElement = document.createElementNS(namespaceURI, tagName); - - // Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc. - // However, they are allowed to be executed when document.write() is used. - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement - if (tagName === 'script') { - (newElement)._evaluateScript = evaluateScripts; - } + while ((match = markupRegexp.exec(data))) { + const tagName = match[2].toLowerCase(); + const isStartTag = !match[1]; - // An assumption that the same rule should be applied for the HTMLLinkElement is made here. - if (tagName === 'link') { - (newElement)._evaluateCSS = evaluateScripts; + if (parent && match.index !== lastTextIndex) { + const text = data.substring(lastTextIndex, match.index); + this.appendTextAndCommentNodes(document, parent, text); } - this.setAttributes(newElement, match[3]); + if (isStartTag) { + const namespaceURI = + tagName === 'svg' + ? NamespaceURI.svg + : (parent).namespaceURI || NamespaceURI.html; + const newElement = document.createElementNS(namespaceURI, tagName); + + // Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc. + // However, they are allowed to be executed when document.write() is used. + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement + if (tagName === 'script') { + (newElement)._evaluateScript = evaluateScripts; + } - if (!match[4] && !VoidElements.includes(tagName)) { - // Some elements are not allowed to be nested (e.g. "" is not allowed.). - // Therefore we will auto-close the tag. - if (parentUnnestableTagName === tagName) { - stack.pop(); - parent = parent.parentNode || root; + // An assumption that the same rule should be applied for the HTMLLinkElement is made here. + if (tagName === 'link') { + (newElement)._evaluateCSS = evaluateScripts; } - parent = parent.appendChild(newElement); - parentUnnestableTagName = this.getUnnestableTagName(parent); - stack.push(parent); - } else { - parent.appendChild(newElement); - } - lastTextIndex = markupRegexp.lastIndex; - - // Tags which contain non-parsed content - // For example: